diff --git a/apps/client/__tests__/derive.test.ts b/apps/client/__tests__/derive.test.ts index e6deb13d2..ba0afacc6 100644 --- a/apps/client/__tests__/derive.test.ts +++ b/apps/client/__tests__/derive.test.ts @@ -211,7 +211,27 @@ describe('deriveRowStatus', () => { ), ) as { $defs: Record }; - const session = schema.$defs.subscribeThreadOutput__Objects_6; + /* + * `_7`, not `_6`, and the number is generated rather than chosen. + * + * Spec 250 phase 5 regenerated the vendored contract FROM THE FORK, and the + * fork's `codevGate` object lands ahead of the session object in the + * generator's numbering — so the enum this test reads moved one along. The + * mapping below is unaffected; only the path was stale. + * + * The assertion message under this is what made that diagnosable, and it is + * why the message says what it says: "this test needing a new path, not a + * mapping change" is the difference between a one-character fix and an hour + * spent looking at `deriveRowStatus`. + * + * A positional key like this WILL move again whenever the contract is + * regenerated after a change ahead of it. That is the cost of reading a + * generated artifact positionally, and it is accepted here rather than + * hidden: the alternative — searching every `$def` for one carrying a status + * enum — would silently find a DIFFERENT object if the session one ever lost + * its enum, which is the failure this test exists to catch. + */ + const session = schema.$defs.subscribeThreadOutput__Objects_7; const declared = session?.properties?.status?.enum; expect( declared, diff --git a/codev-skeleton/resources/lessons-critical.md b/codev-skeleton/resources/lessons-critical.md index d448a3476..c241a5732 100644 --- a/codev-skeleton/resources/lessons-critical.md +++ b/codev-skeleton/resources/lessons-critical.md @@ -10,6 +10,8 @@ STARTER: a few universal lessons are seeded; add your project's as you learn the - Check for existing work (PRs, git history) before building from scratch. - "It compiled" / "tests pass" is not "it works" — verify the real user path before calling it done. - When stuck (2 failed hypotheses or ~30 min), get an outside perspective instead of guessing. +- A test that cannot fail is not a test — revert the fix and confirm the test fails before trusting it. +- A test that supplies the boundary itself cannot tell you the boundary exists — test the seam, not the two ends. - ## Map of lessons-learned.md (consult when…) diff --git a/codev/plans/250-t3code-front-end-customization.md b/codev/plans/250-t3code-front-end-customization.md new file mode 100644 index 000000000..0ca83a372 --- /dev/null +++ b/codev/plans/250-t3code-front-end-customization.md @@ -0,0 +1,1409 @@ +# Plan: t3code is the front end — private customization + +**Specification**: [codev/specs/250-t3code-front-end-customization.md](../specs/250-t3code-front-end-customization.md) + +## Executive Summary + +The spec chose approach 2: **a fork of t3code, rebased onto upstream**, at +`github.com/pseudoseed/t3code` branch `codev`, checked out at `/Users/chris/dev/t3code-codev`. +`/Users/chris/dev/t3code` stays the read-only upstream clone at `082e6ea5`, because every piece +of spec 146 and 236 evidence was gathered against it. + +That choice drives the phase order, because the work spans **two repositories** and the vendored +contract sits between them: + +1. the **fork** (`/Users/chris/dev/t3code-codev`) — contract fields, migrations, projections, + write-time invariants, the gate block and scope, sidebar, tiling, proxy; +2. **this repository** — the vendoring harness (`pin.json`, `verify`, `classify-churn`, + `generate`), `porch-driver`, `codev-agent`, and the tests that hold both ends honest. + +The vendored contract is regenerated from the fork exactly once mid-flight (phase 5), which +splits the work cleanly: everything before it changes the fork's contract, everything after it +consumes the regenerated one. The two-identity harness is built **first** (phase 1), while fork +HEAD still equals `upstreamBase`, so its own correctness is provable against a no-op diff before +any customization exists to confuse it. + +### "Fork" here means a private repo, not a GitHub fork + +**Ruled by the architect on 2026-08-30, and the reasoning matters because the next reader will +reach for `gh repo fork` as the obvious verb.** + +A GitHub fork of a public repository **inherits that repository's visibility**. There is no +"fork it and make it private" — so forking `pingdotgg/t3code` would publish every customization we +make to anyone who looks, t3code's own authors included. That is the direct opposite of this +spec's ruling, which is *private* customization. + +So `pseudoseed/t3code` is a **private repository with upstream as a remote**, not a fork in +GitHub's sense. It does not need to be one: rebasing onto upstream works identically either way, +which is the only capability the plan actually depends on. + +```bash +gh repo create pseudoseed/t3code --private +git -C /Users/chris/dev/t3code-codev remote add origin git@github.com:pseudoseed/t3code.git +git -C /Users/chris/dev/t3code-codev remote add upstream https://github.com/pingdotgg/t3code.git +``` + +**`gh repo fork` is never run.** Recorded as a prohibition rather than a preference, because the +mistake is one command and is not reversible by deleting the repo afterwards — a public repo that +existed has been indexable. + +t3code is **MIT** (`LICENSE`, `Copyright (c) 2026 T3 Tools Inc.` — verified, not assumed), so the +private copy keeps the licence file and its attribution intact. Nothing about keeping the work +private removes that obligation. + +The word "fork" is kept everywhere else in this plan, and in `pin.json`'s field names, for the +tree that carries our commits. It is a description of the git relationship, not a claim about +GitHub's fork feature. + +### The added columns do not go through upstream's migrator + +Plan review round 1 found this and I verified it in +`node_modules/.pnpm/effect@4.0.0-beta.103/.../unstable/sql/Migrator.js`. Upstream's migrator is a +**watermark**, not a set difference: + +```js +const latestMigration = sql`SELECT migration_id, name, created_at FROM ${sql(table)} ORDER BY migration_id DESC` // :78 +if (currentId <= latestMigrationId) { continue; } // :121 +``` + +So any id we register becomes `MAX(migration_id)`. A high number — the first draft of this plan +said 900, reasoning that a big gap avoids collision — sets the watermark to 901, and **every +upstream migration that arrives later (043, 044, …) is silently skipped** while the migrator logs +that the schema is current. The mitigation inverted its own goal: it converted a loud collision +into silent schema divergence, at exactly the moment the plan calls routine. Tail numbering +(043, 044) is no better; it collides on the next upstream bump and needs a per-rebase rewrite of +recorded rows in `effect_sql_migrations`, on a database whose only backup is the owner's. + +**So Codev's columns never enter `migrationEntries`.** They are applied at server start by a +guarded, idempotent `PRAGMA table_info` + `ALTER TABLE … ADD COLUMN`, which never reads or writes +`effect_sql_migrations` and leaves the watermark exactly where upstream put it. + +**The guard is upstream's own idiom, not an invention.** Verified: upstream already adds nullable +columns exactly this way. `042_ProjectionThreadLinkedPullRequest.ts` in full — + +```ts +const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; +if (!columns.some((column) => column.name === "linked_pull_request_json")) { + yield* sql`ALTER TABLE projection_threads ADD COLUMN linked_pull_request_json TEXT`; +} +``` + +`033_ProjectionThreadsSettled.ts`, `034`, `035`, `039`, `040`, `021`, `022` and `032` all do the +same. So our column applier is that code verbatim; the only difference is **where it is invoked +from** — a layer sequenced after `MigrationsLive` (`Migrations.ts:173`) rather than an entry in +`migrationEntries`. + +That narrows the deviation to one question: registry membership. **Ruled by the architect on +2026-08-30 — stay out of the numbered registry, and this supersedes the spec's risk row.** The +reasoning, recorded because it is not the obvious answer: a number we occupy is a number upstream +will eventually want, and that collision is silent. Two entries claiming `043` means one is +skipped and its column never appears, which reads at runtime as "not recorded" rather than as a +failed migration. The spec's own mitigation — "number ours far above upstream's range" — is +obsolete for the same reason: a high number is still inside upstream's sequence and still collides +once they reach it. A separate layer cannot collide at all. + +**The cost is real and is recorded rather than argued away:** our column addition never appears in +upstream's migration history, so someone debugging a schema question reads `migrationEntries` and +`effect_sql_migrations` and does not see ours. Mitigated by logging it once at start-up under a +named signal, so "our columns were added" is observable somewhere rather than only inferrable from +the schema itself. + +### Two repositories, one PR + +The fork's commits live on `pseudoseed/t3code@codev` and **cannot appear in this repository's +PR**. Three things bridge that gap, and none of them is "apply a patch to a checkout" — spec +approach 1 is rejected and stays rejected: + +- `pin.json` records the fork commit, so the Codev tree names exactly what it was built against. +- `tools/t3-fork/FORK.md` records the remote, branch, checkout path, and a phase-to-commit log. +- Phase 5 exports `git format-patch upstreamBase..forkHEAD` into `tools/t3-fork/patches/` as a + **review aid** — so a reviewer of the Codev PR can read the six changes without cloning the + fork. It is never the mechanism by which the fork is built or rebased. + +### The browser harness lives in this repository, not the fork + +Criteria 1, 2, 3, 5, 5b and 7 are all "verified in t3code's own web app", and 5 and 5b are +measurements — pane boxes in CSS px, computed font size — that only a real browser produces. + +**t3code has no browser test tooling at all.** Verified rather than assumed: no `playwright` +anywhere in its `package.json` files, and `apps/web`'s entire test script is +`vp test run --passWithNoTests --project unit` with `@effect/vitest` as its only test dependency. +The first draft of this plan named Playwright verification in three phases without checking that. + +This repository already has `@playwright/test ^1.58.0` in `packages/codev`, `apps/client`, +`apps/v2` and `packages/artifact-canvas`. So the measurement harness lives **here**, driving the +fork's dev server over HTTP, rather than being added to the fork. Two reasons, in order: it keeps +the fork diff narrow, which the spec names as the mitigation for unmergeability; and the criteria +belong to Codev, so the tests that close them belong in Codev's tree where they run in Codev's CI. + +The cost is that these tests need a running fork — `pnpm dev:web` plus a server and a seeded +project — so they are gated on that being up and **report a skip as a skip**, never as a pass. + +### Phase-to-criterion map + +| Phase | Success criteria it closes | +|---|---| +| 1 | part of 9 (`verify` two identities, churn ranges) | +| 2 | 8, 8b | +| 3 | 11 | +| 4 | 10 | +| 5 | part of 9 (regeneration, `shape-check`) | +| 6 | prerequisite for 1–4 | +| 7 | 1, 2, 7, 11 (orphan rendering) | +| 8 | 3 | +| 9 | 5, 5b | +| 10 | 4 | +| 11 | 6, and 9 end to end | + +## Phases (Machine Readable) + + + +```json +{ + "phases": [ + {"id": "phase_1", "title": "Two-identity vendoring harness"}, + {"id": "phase_2", "title": "Thread hierarchy in the fork's contract and projection"}, + {"id": "phase_3", "title": "Hierarchy integrity refused at write time"}, + {"id": "phase_4", "title": "Porch gate block with a server-allocated revision"}, + {"id": "phase_5", "title": "Vendored contract regenerated from the fork"}, + {"id": "phase_6", "title": "Hierarchy and gate state published by porch-driver and codev-agent"}, + {"id": "phase_7", "title": "Workspace to architect to builder sidebar"}, + {"id": "phase_8", "title": "Gate rendering in t3code"}, + {"id": "phase_9", "title": "Builder tiling"}, + {"id": "phase_10", "title": "Approval from t3code over the same-origin proxy"}, + {"id": "phase_11", "title": "Acceptance run: tailnet iPad and the rebase drill"} + ] +} +``` + +## Phase Breakdown + +### Phase 1: Two-identity vendoring harness + +**Dependencies**: None + +#### Objective + +Make the vendoring machinery able to hold **two** checkouts with two different meanings before +either diverges. Built first on purpose: while fork HEAD still equals `upstreamBase`, every new +assertion has a known answer, so a harness bug cannot hide inside a real customization diff. + +This phase creates a repository under `pseudoseed`. It is **private**, and it is created with +`gh repo create --private`, never `gh repo fork` — see the executive summary for why the two are +not interchangeable here. + +#### Files to Create / Modify + +Fork side (new repository state, no Codev commit): +- `github.com/pseudoseed/t3code` — **created private via `gh repo create --private`**, not forked. + Branch `codev`, checked out at `/Users/chris/dev/t3code-codev`, based on + `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6`. Remotes: `origin` is the private repo, `upstream` is + `https://github.com/pingdotgg/t3code.git`. +- `/Users/chris/dev/t3code` is **not touched by this phase or any other**. It stays the read-only + upstream clone at the pin, because every piece of spec 146 and 236 evidence verifies against it. + +This repository: +- `packages/types/src/t3/pin.json` — add `upstreamBase`, `forkRepo`, `forkBranch`; `commit` + keeps its meaning and becomes the **fork** head once phase 5 runs. +- `tools/t3-server/t3-server.mjs` — **`verify`, `acquire`, `start` and `status`**, not `verify` + alone. See the deliverable below: leaving `acquire` on `pin.commit` is destructive. +- `tools/t3-codegen/classify-churn.mjs` — two named ranges, two checkouts. +- `tools/t3-codegen/generate.mjs` — **the root switch is the load-bearing edit**: `:51` reads + `T3CODE_ROOT` and `:78` refuses when `git rev-parse HEAD` there does not equal `pin.commit`. + Since `pin.commit` becomes the fork head, generation must read the **fork** root. Plus + `source-hash.json` records the upstream closure hash at `upstreamBase` alongside the fork's. +- `tools/t3-server/smoke.mjs` (`:177`), `tools/t3-codegen/transform-blindness-probe.mjs` (`:29`), + `packages/t3-client/live/integration.mjs` (`:77-79`, `:213`, `:219`) — the other + `T3CODE_ROOT` readers, each assigned to an identity deliberately. +- `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` (`:43`, `:143`, `:322-338`) — the + seventh reader, and the suite phase 1 breaks (see deliverables). +- `codev/research/146-harness-coldstart-evidence.json` — re-collected, because editing + `t3-server.mjs` invalidates it. +- `tools/t3-codegen/REFRESH.md` — the two-identity refresh procedure. +- `tools/t3-fork/FORK.md` — new. Remote, branch, checkout path, phase-to-commit log. +- `packages/codev/src/__tests__/spec-250-vendoring-identities.test.ts` — new. + +#### Deliverables + +- [ ] **Private** repository created at `pseudoseed/t3code` and checked out at + `/Users/chris/dev/t3code-codev` on branch `codev`, with `origin` and `upstream` remotes set. + Its visibility is asserted (`gh repo view pseudoseed/t3code --json visibility`) rather than + assumed from the create command having succeeded. +- [ ] The MIT `LICENSE` and its attribution are present and unmodified in the private copy. +- [ ] `/Users/chris/dev/t3code` is byte-identical to how the phase found it — same HEAD, clean + tree. Asserted at the end of the phase, because two of this plan's worst findings were about + something writing into that clone by accident. +- [ ] `pin.json` carries `{ "commit": "", "upstreamBase": "082e6ea5…" }`. +- [ ] `t3-server.mjs verify` asserts, per identity: + - `upstreamBase`: `/Users/chris/dev/t3code` HEAD equals `upstreamBase`, tree clean; + - fork: `/Users/chris/dev/t3code-codev` HEAD equals `commit`, tree clean, and + `git merge-base ` equals `upstreamBase`. +- [ ] Checkout roots resolve per identity — `T3CODE_ROOT` for upstream (unchanged meaning) and + `T3CODE_FORK_ROOT` for the fork, each defaulting to its spec'd path. One variable is not + stretched over two meanings. +- [ ] **All seven `T3CODE_ROOT` readers are assigned, not three.** Verified by grep, not assumed: + + | Reader | Identity | Why | + |---|---|---| + | `tools/t3-server/t3-server.mjs:38` | both | it is the verifier | + | `tools/t3-codegen/generate.mjs:51,78` | **fork** | generation is fork-sourced from phase 5 | + | `tools/t3-codegen/classify-churn.mjs:38` | both | one per range | + | `tools/t3-codegen/transform-blindness-probe.mjs:29` | fork | it probes what we emit | + | `tools/t3-server/smoke.mjs:177` | upstream | keeps the spec-146 evidence reproducible | + | `packages/t3-client/live/integration.mjs:77,213,219` | upstream | spec 146 / #241 live tests, meaning unchanged | + | `packages/codev/src/__tests__/spec-146-t3-contract.test.ts:43` | upstream | asserts the upstream harness | + +- [ ] **The cold-start evidence is re-collected.** `spec-146-t3-contract.test.ts:254` fails if + `codev/research/146-harness-coldstart-evidence.json` is older than `t3-server.mjs` or + `smoke.mjs`, and this phase edits `t3-server.mjs`. That test is doing its job — it exists + so a harness change cannot ride on stale evidence — so the evidence is regenerated against + a live pinned server, not the assertion loosened: + + ``` + export T3_NODE=/absolute/path/to/node + "$T3_NODE" tools/t3-server/smoke.mjs --runs 2 > codev/research/146-harness-coldstart-evidence.json + ``` + + Needs a live server at the pinned commit. Planned as a step, not discovered as a failure. +- [ ] **`acquire`, `start` and `status` are pinned to `upstreamBase`, not to `pin.commit`.** + Review round 1 caught this and it is the one item here that can destroy something. + `acquire()` does `gitIn(t3Root, 'checkout', '--detach', pin.commit)` (`t3-server.mjs:94`) + against `T3CODE_ROOT` — the read-only upstream clone. Once phase 5 moves `pin.commit` to the + fork head, that line tries to check a **fork** SHA out into the **upstream** clone, and + `start` (`:389`) and `status` (`:663`) compare against `pin.commit` the same way. Both + `tools/t3-server/smoke.mjs:156` and `packages/t3-client/live/integration.mjs:196` call + `acquire`, so this fires from an ordinary test run, not only from a deliberate invocation. + The upstream clone exists precisely to stay reproducible at `upstreamBase`; rewiring only + `verify` would leave the one verb that *writes* to it still pointing at the fork. +- [ ] Exit `3` (**could not determine**) survives untouched and is still spelled differently from + exit `1`: a missing fork checkout, an unreadable HEAD, or an unresolvable merge-base is `3`, + never `1`. +- [ ] `classify-churn.mjs` takes two ranges with distinct meanings and refuses to conflate them: + - `--upstream-movement` → `upstreamBase..origin/main` read from `/Users/chris/dev/t3code`; + - `--fork-drift` → `upstreamBase..` read from `/Users/chris/dev/t3code-codev`. + Invoked with neither, it fails loudly rather than picking one. +- [ ] A zero result from `--upstream-movement` reports `NO_UPSTREAM_MOVEMENT` and exits `0`, + distinct from the tool failing (`1`) and from it being unable to read a ref (`3`). +- [ ] `source-hash.json` grows an `upstream` section: `{ commit: upstreamBase, files: {…} }` + hashed from the upstream closure, beside the existing fork-sourced hashes. Generation from + the fork stops being a tautology. +- [ ] `FORK.md` and `REFRESH.md` written. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] `node tools/t3-server/t3-server.mjs verify` exits `0` with both checkouts at their pins. +- [ ] Moving either checkout off its pin exits non-zero with a message naming **which** identity + failed; deleting the fork checkout exits `3`, not `1`. +- [ ] `classify-churn --fork-drift` reports zero at this phase (fork equals upstreamBase) and + `--upstream-movement` reports whatever upstream actually did — the two answers are visibly + different questions. +- [ ] `pnpm -w test` green — **including `spec-146-t3-contract.test.ts`**, which needs the + re-collected evidence above. A green run that skipped that suite for want of a checkout is + not a pass and is reported as a skip. +- [ ] Build and typecheck pass. + +#### Test Plan + +- Unit: pin parsing with and without `upstreamBase`; the three exit codes as three distinct + outcomes; range construction for both churn modes. +- Regression: `spec-146-t3-contract.test.ts` run with `T3CODE_ROOT` set, so the live suite + actually executes rather than skipping. +- Integration: throwaway git repositories standing in for both checkouts — pinned, moved, dirty, + absent, and a fork whose merge-base is *not* `upstreamBase` (a rebase that dropped the base), + which must fail rather than pass quietly. +- Manual: run `verify` against the real pair. + +### Phase 2: Thread hierarchy in the fork's contract and projection + +**Dependencies**: Phase 1 + +#### Objective + +`role` and `parentThreadId` exist on the fork's thread record, survive a projection rebuild over +a **pre-fork** event log, and do not stop a pre-fork server opening the database. + +#### Files to Create / Modify + +All in `/Users/chris/dev/t3code-codev`: +- `packages/contracts/src/orchestration.ts` — `CodevThreadRole` (`architect` | `builder`), + `role` and `parentThreadId` on `OrchestrationThreadShell`, `OrchestrationThread`, and + `ThreadCreatedPayload`, each with a decoding default of `null`. +- `apps/server/src/codev/schemaGuard.ts` — new. The guarded, idempotent column applier. **Not** + a file under `Migrations/`, and **not** registered in `Migrations.ts`. +- `apps/server/src/persistence/Migrations.ts` — export a `CodevSchemaGuardLive` layer sequenced + **after** `MigrationsLive` (`:173`) and before the projection layers open. `migrationEntries` + itself is not touched. +- `apps/server/src/orchestration/projector.ts` — read and write the columns. +- `apps/server/src/orchestration/Schemas.ts` — re-export as the file already does. +- **The columns must flow through the whole persistence path, not just the in-memory projector.** + Review round 1 caught the first draft naming too few modules; all four verified to exist: + - `apps/server/src/persistence/Services/ProjectionThreads.ts` + - `apps/server/src/persistence/Layers/ProjectionThreads.ts` + - `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` + - `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` +- `packages/contracts/src/orchestration.test.ts` — decode cases. +- `apps/server/src/orchestration/projector.codevHierarchy.test.ts` — new. +- `apps/server/src/codev/schemaGuard.test.ts` — new. + +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged. This is the phase's only artifact in + this repository, and it is what makes a fork-only phase committable here. + +#### Deliverables + +- [ ] `role` is `"architect" | "builder" | null`; `null` means a thread Codev did not create. +- [ ] `parentThreadId` is a nullable `ThreadId`. +- [ ] **The columns are applied outside upstream's migrator, using upstream's own idiom.** + `schemaGuard.ts` reads `PRAGMA table_info(projection_threads)` and issues + `ALTER TABLE … ADD COLUMN` only for absent columns — the same shape as + `042_ProjectionThreadLinkedPullRequest.ts` and seven other upstream migrations. It never + reads or writes `effect_sql_migrations`, so upstream's watermark (`Migrator.js:78`, `:121`) + stays where upstream put it and every future upstream migration still runs. +- [ ] The guard is idempotent and safe to run on every start: present columns are left alone, + absent ones are added, and running it twice changes nothing the second time. +- [ ] **The guard logs once at start-up under a named signal** — `CODEV_SCHEMA_GUARD_APPLIED` + with the columns it added, and a distinct `CODEV_SCHEMA_GUARD_NOOP` when everything was + already present. This is the agreed mitigation for the one real cost of staying out of the + registry: our column addition is absent from upstream's migration history, so without a log + line it is inferrable only by reading the schema. Two signals, not one, because "added two + columns" and "had nothing to do" are different facts and a single line covering both is the + thing that makes the log useless. +- [ ] `ALTER TABLE … ADD COLUMN` only. Nothing is rewritten, no table is recreated, no data is + backfilled. +- [ ] The **first draft's migration 900 is explicitly abandoned**, and `FORK.md` records why, so + nobody reintroduces a high id later reasoning that a big gap is safe. It is the opposite of + safe under a watermark migrator. +- [ ] `ThreadCreatedPayload` events already in the log decode with both fields defaulting to + `null` — decoding **defaults**, never fails. +- [ ] **Start-up layer ordering is stated, not left to construction order**, and asserted by a + test: `SqliteClient` → `MigrationsLive` → `CodevSchemaGuardLive` → the projection layers. + A repository query that runs before the guard reads a table without our columns, and the + failure would look like missing data rather than a boot-order bug. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 8**: a populated pre-fork database opens against the customized server; the + added columns read as `null` ("not recorded"), not as a guessed role; and a projection + rebuilt from a pre-fork event log decodes every historical `ThreadCreatedPayload`. +- [ ] **A newly introduced upstream migration still runs after Codev's columns exist.** This is + the test the first draft was missing, and it is the one that would have caught migration + 900: apply the guard, then add a fake upstream migration at the next free id, and assert it + actually executes rather than being skipped. +- [ ] **Criterion 8b**: the server is killed partway through applying the columns and the + resulting database still opens against the **pre-fork** server binary. + + Worth stating why this test now proves something. Under the abandoned migrator route it + would have passed **by construction** — `Migrator.js:142` wraps the whole run in + `sql.withTransaction` and SQLite DDL is transactional, so a kill rolls everything back and + the criterion is met without the code being careful. Outside the migrator there is no such + wrapper: two `ALTER` statements are two atomic steps, a kill between them leaves exactly one + column added, and it is the `PRAGMA table_info` guard that makes the next start finish the + job. The kill test now discriminates. +- [ ] Fork typecheck green, and fork tests green **for the packages this phase touches** + (`@t3tools/contracts`, the server's orchestration and persistence suites). A full-monorepo + `pnpm test` is run once, at phase 11 — making it an acceptance criterion on six separate + phases buys nothing and costs a long run each time. + +#### Test Plan + +- Unit: decode a `ThreadCreatedPayload` with no `role`/`parentThreadId`; encode round-trip; + shell decode with the fields present and absent. +- Integration: build a pre-fork database fixture (populated by the pinned server), migrate it, + read it; then rebuild the projection from its event log and compare thread counts and ids. +- Fault injection: `SIGKILL` between the two `ALTER` statements, then open the file with the + pinned server, then restart the customized server and confirm the guard completes the job. +- Regression: a fake upstream migration at the next free id runs after the guard has applied. +- Regression: `effect_sql_migrations` is byte-identical before and after the guard runs. + +### Phase 3: Hierarchy integrity refused at write time + +**Dependencies**: Phase 2 + +#### Objective + +Every illegal edge is **refused by the server when it is written**, with a named signal. No +fallback rendering, because a fallback is a second correct-looking answer. + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `apps/server/src/orchestration/commandInvariants.ts` — the five refusals. +- `apps/server/src/orchestration/decider.ts` — apply them on `thread.create` and on any command + that sets `role` or `parentThreadId`. +- `apps/server/src/orchestration/Errors.ts` — `CodevHierarchyInvalid` with a reason discriminant. +- `apps/server/src/orchestration/decider.codevHierarchy.test.ts` — new. +- `apps/server/src/orchestration/commandInvariants.test.ts` — extend. +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged; this phase's only artifact here. + +#### Deliverables + +- [ ] Refused: a builder whose `parentThreadId` names an absent thread, a thread in another + project, or itself. +- [ ] Refused: a builder parented to another **builder**, or to a `role: null` thread. The only + legal edge is architect → builder. +- [ ] Refused: a builder with **no** `parentThreadId`. +- [ ] Refused: a thread with `role: "architect"` or `role: null` carrying a `parentThreadId`. +- [ ] Each refusal carries its own reason discriminant, so a caller can tell "no such parent" + from "wrong parent role" — one generic error for five causes is not enough to act on. +- [ ] A parent **archived or deleted** after the fact is not retro-refused; its children become + orphans, recorded as such, and rendered in a stated unattributed group in phase 7. + `archivedAt` already exists, so archiving is the likelier case and is the one tested first. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 11**: each listed case is refused at write time, verified against the decider, + not against the UI. +- [ ] Archiving an architect leaves its builders readable and marked orphaned, not dropped and + not deleted. +- [ ] Fork typecheck green; fork tests green for the orchestration suites this phase touches. + +#### Test Plan + +- Unit: one decider test per refusal case, asserting the discriminant and not just the failure. +- Unit: legal architect → builder edge accepted; `role: null` thread with no parent accepted. +- Integration: archive a parent with live children, then read the shell snapshot. + +### Phase 4: Porch gate block with a server-allocated revision + +**Dependencies**: Phase 3 + +#### Objective + +A porch gate is first-class state on the thread record, protected by a **server-allocated** +monotonic revision that survives the gate being cleared, and writable only by a credential +holding a new `codev:gate-write` scope. + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `packages/contracts/src/orchestration.ts` — `CodevGate` (gate name, `requestedAt`, #128's + structured `question` and `choices`), nullable `codevGate` on the thread record, and + `gateRevision`. A `codev.gate.set` / `codev.gate.clear` command pair. + + **Amended during implementation, architect-approved.** `gateRevision` was specified as + *non-nullable on the wire*. That form cost **159 errors across upstream test fixtures** — not + five times the 32 that phase 2 rejected but a different category: 32 was a bill, 159 is a + permanent tax on every rebase, and the fork's whole value is that rebasing stays cheap. It is + therefore `Schema.optional(NonNegativeInt)` with a **decoding default of `0`**: optional on the + wire, always a number after decoding. + + The invariant criterion 10 actually needs is *"the mark is always a number, and there is exactly + one spelling of no-gate-yet"*, and that is carried by the database plus normalize-on-read rather + than by the wire type: `codev_gate_revision INTEGER NOT NULL DEFAULT 0`, with every read path + using `?? 0`. Two tests hold it up, both verified to fail when the mechanism is removed: + the column is asserted to **reject a NULL** (against SQLite, on a real row — not by grepping the + DDL string), and a record decoded **with the field absent on the wire** is asserted to be a + number and not `undefined`, at the boundary `porch-driver` crosses. +- `packages/contracts/src/auth.ts` — `AuthCodevGateWriteScope = "codev:gate-write"`. +- `apps/server/src/auth/RpcAuthorization.ts` — map the **new RPC method** to the new scope. +- `apps/server/src/persistence/Services/ProjectionThreads.ts`, + `apps/server/src/persistence/Layers/ProjectionThreads.ts`, + `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`, + `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` — the gate block and + `gateRevision` flow through the same four modules phase 2 threads the hierarchy columns through. +- `apps/server/src/codev/schemaGuard.ts` — extended with the gate columns. Same mechanism as + phase 2: guarded, idempotent, outside `migrationEntries`, watermark untouched. +- `apps/server/src/orchestration/decider.ts` + `projector.ts` — allocate and enforce the revision. +- `apps/server/src/auth/…` — grant `codev:gate-write` to exactly one credential provisioned out + of band at server start; never issue it to a thread. +- `apps/server/src/orchestration/decider.codevGate.test.ts` — new. +- `apps/server/src/auth/CodevGateScope.test.ts` — new. +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged; this phase's only artifact here. + +#### Deliverables + +- [ ] The gate block carries gate name, requested-at, and #128's structured question with one to + five choices, each with a label and a consequence, and at most one marked recommended. +- [ ] `gateRevision` is stored **separately from the block** and is a non-nullable high-water + mark that only ever increases — including across a clear. Clearing is a write like any + other and raises the mark. +- [ ] **The revision field is optional, and that is what makes allocation and stale-rejection + coexist.** The first draft asserted both "`codev-agent` sends no revision, the server + allocates" and "a write carrying a lower revision is rejected", which review round 1 + correctly called not implementable as written — if no write ever carries a revision, there + is nothing to reject and criterion 10 has nothing to deliver. Resolved as one rule: + + | `revision` on the command | Server behaviour | + |---|---| + | absent — the normal path | allocate `gateRevision + 1` atomically, apply, return it | + | present — a replay, or a write from an older connection | apply **only if it exceeds** the current mark; otherwise refuse with `CODEV_GATE_REVISION_STALE` | + + Equal is refused, not treated as idempotent. Criterion 10's stale write is exactly the + second row. +- [ ] **The allocated revision is returned on the new RPC's own response**, not through + `dispatchCommand` — an earlier draft of this phase said `dispatchCommand`, which was left + over from before the gate commands moved off it. A gate write whose response cannot be read + is reported as unconfirmed, never as applied. +- [ ] A counter held in `codev-agent`'s memory is explicitly not used: it resets on restart, and a + reset counter renders every later gate as *no gate pending*, a false negative exactly where + a human is waiting. +- [ ] Historical rows default to `0`, applied by the same guard rather than by a registered + migration — a `DEFAULT 0` on the added column, so no backfill pass is needed. A backfill + would be a second write that can be interrupted. +- [ ] **The `NOT NULL` is asserted against the database, not the DDL string**, and a record + decoded with `gateRevision` absent on the wire is asserted to be a number. Both are the + conditions attached to the optional-on-the-wire amendment above: with the mark optional on + the wire, "always a number" is carried entirely by these two, so both must be tests that + can fail rather than sentences. +- [ ] **Gate writes travel a separate RPC method, because the existing authorization point cannot + see command types.** Verified: `apps/server/src/auth/RpcAuthorization.ts:24` maps + `ORCHESTRATION_WS_METHODS.dispatchCommand` as a whole to `AuthOrchestrationOperateScope`. + It authorizes the *method*, so routing `codev.gate.set` through `dispatchCommand` and hoping + to scope it separately is not expressible — every operator would reach it. + +- [ ] **The whole RPC surface is named, because a row in the scope map alone does not compile.** + Review round 1 caught this: `RpcAuthorization.ts:130` is + `satisfies Readonly>`, and `WsRpcMethod` derives + from `WsRpcGroup` in `packages/contracts/src/rpc.ts` — a file deliberately **outside** the + vendored closure. So adding only the authorization row is a type error. Four places, in + order: + + | Where | What | + |---|---| + | `packages/contracts/src/rpc.ts` | `Rpc.make` for `codev.gateWrite`, and membership in `WsRpcGroup` | + | `ORCHESTRATION_WS_METHODS` (or equivalent) | the method constant | + | `apps/server/src/ws.ts` (`:1174`, `WsRpcGroup.of({…})`) | the handler key | + | `apps/server/src/auth/RpcAuthorization.ts` | the row pointing at `codev:gate-write` | + + Avoiding a command-type branch inside `dispatchCommand` is still right; a separate handler + key is not that branch. +- [ ] **The gate commands stay out of `ClientOrchestrationCommand` and + `DispatchableClientOrchestrationCommand`** (`packages/contracts/src/orchestration.ts:935-987`). + Those unions *are* the `dispatchCommand` payload, so putting the gate commands in them would + hand gate-writing to every holder of `orchestration:operate` and silently bypass the new + scope — undoing this phase's entire point. Internal-only commands already exist as + precedent: `ThreadSessionSetCommand` is not in the client union. +- [ ] A caller holding only `orchestration:operate` is refused with a **named** signal + (`CODEV_GATE_SCOPE_REQUIRED`), not a generic 403. +- [ ] **The credential path is named, and two exclusions are part of the deliverable.** + `AuthEnvironmentScope` is a closed `Schema.Literals` list of eight + (`packages/contracts/src/auth.ts:84-93`), so the new scope is added there — and `auth.ts` is + on the vendored closure, so this is a contract change phase 5 must regenerate. It must + **not** be added to: + - `AuthStandardClientScopes` (`auth.ts:98-104`) — the set every ordinary client is issued; + - the token allowlist at `apps/server/src/auth/http.ts:265-274`. + + Either would grant gate-writing to exactly the callers this scope exists to exclude. The + phase names the issuance API and the on-disk path of the single credential, rather than + leaving both to the implementer. +- [ ] `hasPendingApprovals` is untouched. Provider tool approvals and porch gates stay separate. +- [ ] Payload limits: an oversize or malformed gate payload is refused at the schema boundary and + does not partially apply. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 10**: clear an approved gate, then deliver a write carrying a lower revision; + the gate does not reappear. +- [ ] A gate replaced by a later gate on the same thread does not resurrect the first. +- [ ] Two concurrent connections writing gates receive two different revisions. +- [ ] `codev:gate-write` refusal is distinguishable from an unauthenticated 401 and from an + ordinary scope failure. +- [ ] Fork typecheck green; fork tests green for the orchestration and auth suites this phase + touches. + +#### Test Plan + +- Unit: revision allocation is monotonic across set, clear, set; stale and equal revisions both + rejected; historical row defaults to `0`. +- Unit: scope enforcement, with `orchestration:operate` alone and with the gate scope. +- Integration: restart the writer between two gates and confirm the mark survived. +- Adversarial: malformed payloads, six choices, an empty question, a multi-line question, + a payload past the size cap. + +### Phase 5: Vendored contract regenerated from the fork + +**Dependencies**: Phase 4 + +#### Objective + +This repository's vendored contract regenerates **from the fork** and passes `shape-check`, so +`porch-driver` and `codev-agent` can send the new fields against a contract that knows them. + +#### Files to Create / Modify + +This repository: +- `packages/types/src/t3/pin.json` — `commit` becomes the fork head carrying phases 2–4. +- `packages/types/src/t3/generated/*` — regenerated (`schema.json`, `schema.ts`, `types.d.ts`, + `methods.json`, `source-hash.json`, `ATTRIBUTION.md`, `LOSSY.md`, `UNREPRESENTED.md`). +- `tools/t3-fork/patches/*.patch` — `git format-patch upstreamBase..forkHEAD`, review aid only. +- `tools/t3-fork/FORK.md` — phase-to-commit log filled in. +- `packages/codev/src/__tests__/spec-250-generated-contract.test.ts` — new. +- `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` — `:231`'s assertion re-scoped + (see deliverables). + +**Amended during phase 5 to match what shipped.** Four files the plan did not name had to change, +each for a reason found by running the work rather than reading it: + +- `tools/t3-codegen/generate.mjs` and `tools/t3-codegen/classify-churn.mjs` — the plan is right + that a method absent from `pin.methods` is silently never vendored, and stops one step short. + The non-`OrchestrationRpcSchemas` branch resolved schema names from `git.ts` and only `git.ts`; + `CodevGateWriteInput` lives in `orchestration.ts`, so adding the pin entry alone fails + generation. Both tools now take the module from the entry's `source`. +- `packages/types/src/t3/shape-check.ts` — `minItems`/`maxItems` implemented. Phase 4's + one-to-five bound on gate `choices` is the first schema in the closure to emit them, and + `shapeCheck` THROWS on an unimplemented keyword rather than passing, so every gate-write payload + check raised `UnsupportedKeywordError` at the call site. This is a strengthening and does not + contradict the "shape-check is not relaxed" deliverable below: nothing that passed now fails, + nothing that failed now passes, and the file's stated semantics are untouched. +- `tools/t3-server/smoke.mjs` and `tools/t3-server/collect-phase10-evidence.mjs` — re-scoping the + test alone was half a fix. Both collectors wrote `pinnedCommit: pin.commit`, so the next + collection would have recorded a fork sha as the provenance of an upstream run. The field is + renamed to `upstreamCommit` and reads `pin.upstreamBase`; the rename (rather than a re-point) is + what stops evidence written under the old meaning being read under the new one. + +#### Named inputs from earlier phases + +- **`orchestration.subscribeThread` is `consumed-change-undecidable`.** Measured, not guessed: + after phase 2, `node classify-churn.mjs --fork-drift` classifies the phase-2 fork commit and + reports `orchestration.subscribeThread: unknown (union shape changed; not decidable here)`. The + classifier stops being confident at unions and says so rather than guessing, so this is a real + "we could not tell", not a pass. **This phase must decide it by hand**: read what the union + became, and record whether the change is breaking for a client Codev writes. Regenerating + without deciding it converts an explicit undecidable into a silent green. +- The two spellings on `role` / `parentThreadId` are deliberate and must survive this phase's + regeneration: `withDecodingDefault` on `ThreadCreatedPayload` (the log replays), `Schema.optional` + on the read models (matching `linkedPullRequest`; the strict form costs 32 errors across 11 + upstream test files every rebase). See the phase 2 section of the review. + +#### Deliverables + +- [ ] **`orchestration.subscribeThread`'s undecidable verdict is resolved and recorded**, breaking + or non-breaking, with the union diff that decides it. Not carried forward as "unknown". +- [ ] Regeneration runs against `/Users/chris/dev/t3code-codev`, not the upstream clone. +- [ ] **`codev.gateWrite` is added to `pin.json`'s `methods` map, or it is not vendored at all.** + Verified: `generate.mjs:335` iterates `Object.entries(pin.methods)`, not + `OrchestrationRpcSchemas`, so a method present in the schemas map but absent from + `pin.methods` is silently ignored and never reaches `methods.json`. There is precedent to + follow rather than invent — the `vcs.*` entries are recorded in `pin.methods` exactly + because their method strings live in the unvendored `rpc.ts`, which is the same situation + the new method is in. +- [ ] The closure is unchanged — still the nine files, and this is checkable in advance rather + than discovered at generation time. `ThreadId` is defined in `baseSchemas.ts` + (`:55-56`), which is already on the closure list, and `role` is a plain + `Schema.Literals` union, so neither new field reaches outside. If some later edit does, the + generator fails and the widening is a deliberate decision, not a silent follow. +- [ ] `source-hash.json` carries both the fork hashes and the upstream-at-`upstreamBase` hashes + from phase 1. +- [ ] `shape-check.ts` is **not** relaxed. Its stated semantics — a lower bound in one direction, + stricter in another — hold unchanged; the added fields must not turn it into a claim of + validity it does not make. +- [ ] Patch export committed, with `FORK.md` stating plainly that it is for review and that + patch-application is not how the fork is built or rebased. +- [ ] **`spec-146-t3-contract.test.ts:231` is re-scoped to `upstreamBase`, deliberately.** It + asserts `evidence.pinnedCommit === pin.commit`, and this phase moves `pin.commit` to the + fork head, so it fails here. The cold-start evidence describes the **upstream** harness + starting the **upstream** server; the commit it should be checked against is therefore + `pin.upstreamBase`, not `pin.commit`. Re-collecting it against the fork would be the wrong + fix — it would silently change what the evidence is evidence *of*, and spec 146's criteria + about the pinned harness would stop meaning what they said. +- [ ] **`pin.contractSource` flips from `"upstream"` to `"fork"`, and a test asserts that the flip + turns "ahead of the contract" into an error.** Architect ruling during phase 1: + `pin.commit` means "the vendored contract was generated from this commit", and only + regeneration moves it. So through phases 2-4 the fork checkout is legitimately AHEAD of + `pin.commit`, and `verify` reports `FORK_AHEAD_OF_CONTRACT` at exit `0` rather than + spelling it the same as a real error — a signal that fires for three phases straight is one + people learn to ignore, and then it fires for a real reason and nobody looks. This phase is + where that stops being true. Once regeneration has happened, a fork HEAD that descends from + `pin.commit` means the checkout moved past the contract, which is an error. The deliverable + is not just the field: it is a test that fails if `FORK_AHEAD_OF_CONTRACT` still exits `0` + with `contractSource: "fork"`. (A HEAD that does NOT descend from `pin.commit` is + `FORK_CHECKOUT_MISMATCH` and an error at any time; that half is already enforced.) +- [ ] `FORK.md` gains the **abandonment procedure**, because the spec keeps `apps/client` as the + fallback and never says how to fall back to it: set `pin.commit` to `upstreamBase`, set + `contractSource` back to `"upstream"`, regenerate, re-run `verify`. Four lines, written + while the mechanism is fresh. +- [ ] Tests for this phase. +- [ ] **The undecidable verdict covers all three commits, not the one named.** Running + `--fork-drift` against the finished fork reports `consumed-change-undecidable` for the phase + 2 and phase 4 `subscribeThread` commits AND the phase 3 `dispatchCommand` commit. Same class + of question; all three are decided. + +#### Acceptance Criteria + +- [ ] **Criterion 9, regeneration half**: the contract regenerates from the fork and + `shape-check` is green. +- [ ] `role`, `parentThreadId`, `codevGate` and `gateRevision` are present in `schema.json` and + typed (not `unknown`) in `types.d.ts`. +- [ ] `verify` passes both identities with the new `pin.commit`, and with `contractSource` now + `"fork"` a checkout one commit ahead of it fails rather than reporting a tolerated + `FORK_AHEAD_OF_CONTRACT`. +- [ ] `pnpm -w test` green, `spec-146-t3-contract.test.ts` included and **not** skipped. + +#### Test Plan + +- Unit: assert the four new fields resolve to real types in the generated artifacts, and that a + round-trip payload with them passes `shapeCheck`. +- Unit: assert `source-hash.json` has both sections and that they differ. +- Regression: `spec-146-t3-contract.test.ts` passes with `:231` re-scoped and every other + assertion untouched. The first draft claimed this suite passes *unchanged*; verified against + the file, it cannot, and saying so was wrong. + +### Phase 6: Hierarchy and gate state published by porch-driver and codev-agent + +**Dependencies**: Phase 5 + +#### Objective + +The two Codev-side producers start supplying what the fork can now hold: `porch-driver` names the +role and the parent at `thread.create`; `codev-agent` maps workspaces to projects and publishes +gate state from `status.yaml`. + +#### Files to Create / Modify + +This repository: +- `packages/porch-driver/src/thread.ts` — `role` and `parentThreadId` on `CreateThreadOptions` + and on the `thread.create` payload. +- `packages/codev/src/agent-farm/thread-backend.ts` — **the file that actually owns this, and the + one the first draft failed to name.** Verified: `:442-450` already resolves a project by + comparing `canonicalWorkspaceKey(project.workspaceRoot)` against the target, and `:785-818` + calls `createProject` when there is no match, all inside `ensureThreadBackendReady`. A new + side module would have been dead code. The `role`/`parentThreadId` supply and the project map + extend **this** path. +- `packages/codev/src/agent-farm/servers/t3-gate-publisher.ts` — new. Reads gate state through + the existing `status-reader.ts` and writes through the `codev.gateWrite` RPC. +- `packages/codev/src/agent-farm/servers/status-reader.ts` — **`status-reader.ts` is a reader with + no publish cycle of its own**, so the phase names the lifecycle that drives the publisher rather + than assuming one exists: it is invoked from the same watch that already notices `status.yaml` + changing, and on reconnect. +- `packages/codev/src/__tests__/spec-250-porch-driver-hierarchy.test.ts` — new. +- `packages/codev/src/agent-farm/__tests__/spec-250-gate-publisher.test.ts` — new. +- `packages/codev/src/agent-farm/__tests__/spec-250-project-map.test.ts` — new. + +#### Deliverables + +- [ ] An architect thread is created with `role: "architect"` and no parent; a builder thread + with `role: "builder"` and the architect's thread id. +- [ ] A workspace with no project gets one created on first spawn, through + `ensureThreadBackendReady`'s existing lookup-then-create path — extended, not duplicated. + `project.create` is **not** idempotent (t3code refuses a second active project for a + workspace root via `requireActiveProjectWorkspaceRootAbsent`, per `thread-backend.ts:382`), + so the existing single-flight guard keyed on the canonical workspace root is load-bearing + and stays. +- [ ] The map's durable source of truth and its restart behaviour are stated: it is derived from + t3code's own project list on connect, not cached across processes, so a restart re-derives + rather than trusting stale state. +- [ ] A `projectId` that no longer resolves is **reported as unresolvable**, not rendered as an + empty workspace. +- [ ] Nothing derives a `projectId` from a path at read time: two checkouts of the same repo are + two workspaces, and a path is not stable across machines. +- [ ] `codev-agent` is the **only** gate writer, and it holds the only `codev:gate-write` + credential. +- [ ] `status.yaml` stays authoritative. The block is a projection of it; any disagreement is + resolved by re-reading `status.yaml`, never the other way. +- [ ] The publisher sends **no revision**; it uses the one the server returns. On reconnect it + republishes current state rather than replaying history. +- [ ] Gate name and #128's structured request travel intact — the gate name never goes in the + thread **title** again. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] A spawned architect and its builders land on the fork with correct roles and parents, + accepted by phase 3's invariants. +- [ ] **A refusal's `reason` discriminant survives the ws/RPC boundary and is readable by the + dispatching client.** Raised by review at the end of phase 3, and it is the one layer above + the engine that nothing has yet tested. This spec has now been caught twice testing below the + layer production uses: phase 2's guard was wired to a layer nothing builds, and phase 3's six + discriminants were being rewritten by `OrchestrationEngine` into a message that was not merely + lossy but false — persisted onto the rejected receipt and replayed verbatim on redispatch. + Both were green in every test beneath the boundary that broke them. `porch-driver` is the + first real client, so this is the phase where the last hop gets exercised: dispatch an illegal + edge over the wire and assert the client can still tell "no such parent" from "wrong parent + role". If the discriminant does not survive serialization, it does not exist. +- [ ] A gate reaching `pending` in `status.yaml` appears as a gate block within one publish + cycle; approving it clears the block. +- [ ] Killing and restarting `codev-agent` mid-gate leaves the rendered gate matching + `status.yaml` (spec test scenario 4). +- [ ] `pnpm -w test` green. + +#### Test Plan + +- Unit: `thread.create` payload shape, including that a builder without a parent is refused + before dispatch rather than at the server. +- Unit: project-map creation, reuse, and the unresolvable case as its own signal. +- Integration: a `status.yaml` fixture walked through pending → approved → next gate, asserting + the command sequence. +- Integration: publisher restart mid-gate; the server's mark, not the publisher's memory, decides. + +### Phase 7: Workspace to architect to builder sidebar + +**Dependencies**: Phase 6 + +#### Objective + +t3code's own sidebar renders the three-level tree, several architects per project, ordinary +t3code threads untouched, and orphans in a stated group. + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `apps/web/src/codev/hierarchy.ts` — new. Pure grouping: shells in, tree out. +- `apps/web/src/components/Sidebar.logic.ts` — consume it. +- `apps/web/src/components/Sidebar.tsx` — render the nesting. +- `apps/web/src/sidebarProjectGrouping.ts` — **composed with, not extended.** Verified: this + module groups *environments* (`EnvironmentPresence` is `local-only` / `remote-only` / `mixed`, + and `allRemoteMembersAreDesktopLocal` distinguishes a WSL sandbox from a real remote). That is + a different axis from architect-to-builder thread nesting, so the first draft's "extend rather + than replace" was the wrong relationship. Codev's hierarchy is its own pure module that runs + over the threads inside a group this one has already formed. +- `apps/web/src/codev/hierarchy.test.ts` — new. +- `apps/web/src/components/Sidebar.logic.test.ts` — extend. +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged; this phase's only artifact here. + +#### Deliverables + +- [ ] Project, then each architect, then that architect's builders. +- [ ] Two architects in one project render as two subtrees, each owning its own builders. +- [ ] Threads with `role: null` keep the **existing flat presentation** in their own section. + Nothing in the new tree claims them. +- [ ] Builders whose parent is archived or deleted render in a stated unattributed group — named + as orphaned, not silently dropped and not re-parented to a guess. +- [ ] Grouping is a pure function with its own unit tests; the component applies what it returns. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 1**: one architect and three builders render as a tree, verified in t3code's + own web app under Playwright, not only in a unit test. +- [ ] **Criterion 2**: two architects, each with its own builders, render as two subtrees. +- [ ] **Criterion 7**: a thread created by t3code's own UI appears where it always did. +- [ ] **Criterion 11's rendering half**: orphans appear in the unattributed group. +- [ ] Fork typecheck green; fork tests green for `apps/web`'s sidebar suites. + +#### Test Plan + +- Unit: one architect / three builders; two architects; mixed `role: null`; orphan; a builder + whose parent is in another project (which phase 3 refuses at write time, so this asserts the + renderer does not invent a fallback for a state that cannot exist). +- Playwright, **from this repository against the running fork**: the three-level tree in the web + app, per `codev/resources/testing-guide.md`. Harness at + `packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts`. Gated on a reachable fork dev + server; an unreachable one is reported as a skip, never as a pass. +- Visual: compare the rendered sidebar against t3code's existing sidebar, since a green test + suite cannot detect a design that lost its chrome. + +### Phase 8: Gate rendering in t3code + +**Dependencies**: Phase 7 + +#### Objective + +A builder blocked on a gate says so in t3code — which gate, its question, its choices — read +from the gate block, never from the title. + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `apps/web/src/codev/GatePanel.tsx` — new. +- `apps/web/src/codev/gateState.ts` — new. Derivation from the shell, as a pure function. +- `apps/web/src/components/Sidebar.tsx` — the row badge. +- `apps/web/src/codev/gateState.test.ts`, `apps/web/src/codev/GatePanel.test.tsx` — new. +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged; this phase's only artifact here. + +#### Deliverables + +- [ ] A gated builder is visually distinct from a settled one. `starting` / `running` / `ready` / + `settled` cannot express "blocked on a human", so the gate state is rendered as its own + thing rather than folded into status. +- [ ] The panel shows gate name, requested-at, the question, and each choice's label and + consequence, with the recommended one marked when there is one. +- [ ] A gate whose block is present but whose structured request is absent renders as "gate + pending, no structured request" — a **third** state, never as "no gate" and never as an + empty question. `porch gate` without `--request-file` is a legitimate, common case. +- [ ] No `dangerouslySetInnerHTML`. Gate text is content, not markup. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 3**: a builder stopped at `plan-approval` shows the gate name and #128's + structured question with its choices, sourced from the gate block. +- [ ] The thread title contains no gate name anywhere in the flow. +- [ ] Fork typecheck green; fork tests green for `apps/web`'s codev suites. Playwright confirms + the rendered panel. + +#### Test Plan + +- Unit: gate present with request, gate present without request, no gate, gate cleared. +- Unit: one to five choices; a choice with no consequence; no recommendation. +- Playwright, from this repository: drive a real builder to `plan-approval` and read the panel. + Same gating rule — a skip is reported as a skip. + +### Phase 9: Builder tiling + +**Dependencies**: Phase 8 + +#### Objective + +Four to six builder threads visible at once inside t3code's chrome, with the geometry ported from +`apps/client/src/responsive/layout.ts` and re-measured against t3code rather than assumed. + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `apps/web/src/codev/layout.ts` — new. Ported pure functions. +- `apps/web/src/codev/BuilderGrid.tsx`, `apps/web/src/codev/BuilderPane.tsx` — new. +- `apps/web/src/routes/…` — a route that hosts the grid. +- `apps/web/src/codev/layout.test.ts` — new. + +This repository: +- `packages/codev/src/__tests__/e2e/spec-250-tiling.spec.ts` — new. The browser measurement, here + rather than in the fork, because the fork has no Playwright and the criteria are Codev's. +This repository: +- `tools/t3-fork/FORK.md` — the phase's fork commit logged; this phase's only artifact here. + +#### Deliverables + +- [ ] The column rule is **"as few rows as fit"**, explicitly not "near-square", which + `apps/client/src/responsive/layout.ts:70-82` records as considered and rejected. +- [ ] Floors: panes at least 340x240 CSS px, body text 13px or larger. +- [ ] `PAGE_PADDING`, `GRID_GAP` and the paging threshold are **re-measured against t3code's + chrome**, which is not `apps/client`'s. The ported constants are a starting point, not an + answer, and the measured values are recorded in the file's comments. +- [ ] A pane renders exactly four things: role-prefixed id, status, porch phase, and the last + three messages addressed to that agent. **Not** a live transcript — six live transcripts is + six continuous subscriptions, a different feature at a different cost. A full transcript is + read by opening one thread full-size, which t3code already does. +- [ ] Below the paging threshold the grid pages rather than shrinks; no horizontal scroll at + 390px. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 5**: six builder threads watchable at 1440x900, panes at least 340x240 CSS px, + body text 13px or larger, **measured from the rendered page** under Playwright. +- [ ] **Criterion 5b**: seven panes at 1920 tile **4x2, not 3x3**. This is the case that + distinguishes the two rules — both give 3 columns at 1440x900, so criterion 5 alone cannot + tell them apart. +- [ ] **Criterion 4b** (added during phase 9, at the architect's direction, after the first + screenshot showed the defect it exists to prevent). The architect does **not** occupy an + equal tile where that makes a ragged row: at 1440x900 six builders and an architect are + 3 + 3 + 1, one lonely card beside two empty slots. It gets a **persistent strip below the + grid** showing identity and status, and **expands to a full pane on demand**. An equal tile + is offered only where four columns fit. + + **Four columns, not "1920 or wider", and the number changed for a reason the next reader + should not undo.** Spec 146 states 4b as a viewport width, which is correct for + `apps/client`: that client owns the whole viewport, so available width and viewport width + are the same number, and it stays correct there — `apps/client` is frozen. This grid sits + behind t3code's sidebar, where 1920 of viewport is 1688 of grid, and a viewport threshold + would offer the tile at 1920 even with the sidebar dragged wide enough that only three + columns fit — the exact ragged row 4b exists to prevent. Four columns is not a proxy for the + reason; it **is** the reason: seven items at four columns is 4 + 3, the ordinary shape of + any grid. + + Keyed on **width alone**, never on the builder count. A count-based rule would move the + architect between strip and tile as builders come and go, which is a layout that reflows + under a reader who did nothing. +- [ ] The same grid at 390px has no horizontal scroll. +- [ ] Fork typecheck green; fork tests green for `apps/web`'s codev suites. + +#### Test Plan + +- Unit: `columnsFor` across 1..8 panes at 1440, 1920 and 700, including the 7-at-1920 case as its + own named test. `architectPlacement` at both viewports 4b names, at its turnover width, and a + test asserting it does not consult the builder count. +- Playwright, from this repository at + `packages/codev/src/__tests__/e2e/spec-250-tiling.spec.ts`: measure pane bounding boxes and + computed font size at 1440x900 with six panes; at 1920 with seven; at 390px for overflow. These + are the numbers criteria 5 and 5b name, and a unit test on `columnsFor` cannot produce them — + it proves the arithmetic, not that the rendered pane is 340px wide inside t3code's chrome. +- The seeded fixture is part of the deliverable: six builder threads with roles and parents, so + the measurement runs against a real tree rather than a mocked grid. +- Playwright, for 4b: six panes and a strip at 1440x900, seven panes and no strip at 1920, and the + strip expanding to a full pane and back with the builders' grid intact behind it. +- Visual: the grid beside the `apps/client` grid, to catch content that was dropped rather than + laid out. + +### Phase 10: Approval from t3code over the same-origin proxy + +**Dependencies**: Phase 9 + +#### Objective + +The gate is approved from t3code's web app, over `codev-agent`'s existing capability path, +same-origin — so the page never makes a cross-origin request in the first place, which is the +guarantee that actually holds here. (There is no page-level CSP in t3code to widen or keep +narrow; see the deliverables.) + +#### Files to Create / Modify + +In `/Users/chris/dev/t3code-codev`: +- `apps/server/src/codev/agentProxy.ts` — new. Same-origin proxy to `codev-agent`, mirroring what + `packages/codev/src/agent-farm/servers/client-static.ts` does for `/m//`. +- `apps/server/src/codev/agentProxy.test.ts` — new. +- `apps/web/src/codev/pairing.ts`, `apps/web/src/codev/approval.ts` — new. Ported from + `apps/client/src/gate/approval.ts`. +- `apps/web/src/codev/PairingPanel.tsx` — new. The pairing entry point. +- `apps/web/src/codev/GatePanel.tsx` — the approve action. + +This repository: +- `packages/codev/src/agent-farm/__tests__/spec-250-t3code-approval.e2e.test.ts` — new. + +#### Deliverables + +- [ ] The web app holds **both** halves the path needs: a **machine credential** redeemed from a + `machine-credential` pairing token, and a **`client-session`** token per session. One is + not the other, and neither alone approves anything. +- [ ] It also holds the `codev-agent` origin and the workspace path identifying which workspace + it is approving in. +- [ ] Both travel the existing ceremony — `afx pair issue --purpose client-session` and the + human-session route — which already exist and are tested. t3code gains a pairing entry + point; it does **not** gain approval authority. +- [ ] t3code's authenticated browser session is never an approval credential. +- [ ] The credential is held in per-origin browser storage. This is a **deliberate departure** + from `apps/client`, which reads an operator-written `~/.agent-farm/client-machines.json` at + mode 0600 that t3code's app has no equivalent of. It is strictly more exposed — an XSS on + the page reaches it, and the proxy sees every forwarded credential — so it is scoped and + revocable by design (`afx pair revoke `), and it is **never Tower's shared key**, + which cannot be revoked for one machine without rotating it for all. +- [ ] The page makes no cross-origin request, **and this is asserted by observing what the page + requests, not by reading a CSP header.** Verified against the fork's tree: t3code sets + `Content-Security-Policy` on `.svg` asset responses only + (`apps/server/src/http.ts:51,62` — `default-src 'none'; style-src 'unsafe-inline'; sandbox`) + and `apps/web/index.html` carries no CSP meta tag. **There is no page-level CSP and + therefore no `connect-src` directive to keep closed.** + + Both the spec's Security section and this plan's first draft said `connect-src 'self'` + "stays closed", which asserts a header that does not exist. The design is unchanged and + still correct — the same-origin proxy means no cross-origin request is *made* — but the + guarantee is structural, not enforced by CSP, and the test must therefore watch the + network rather than parse a header. +- [ ] Adding a page-level CSP is **explicitly not done here** and is recorded as a follow-up. It + would be a change to how every t3code page loads, which is far wider than this spec's + "keep the diff narrow" constraint, and widening the fork to get a guarantee we can already + obtain structurally is the wrong trade. +- [ ] **The proxy's upstream target is server-configured, never browser-selected.** Review round 1 + found this and it is the most consequential item in the phase: the deliverable said the web + app "holds the `codev-agent` origin", and a server proxy that forwards to an origin the + browser names is an SSRF primitive — a route-path allowlist does not constrain the *host*. + So the fork's server holds an allowlist of permitted `codev-agent` origins from its own + configuration, and the browser selects **among** them by index or id, never by URL. +- [ ] Scheme and address rules are explicit and enforced server-side: `http`/`https` only, + loopback or the configured mesh address, no credentials in the URL, and **redirects are not + followed**. An absolute URL arriving in a request field is refused rather than normalised. +- [ ] **Hop-by-hop stripping is dynamic, not a fixed list.** + `client-static.ts:329-337` builds the strip set from `HOP_BY_HOP` *plus the tokens named by + the request's own `Connection` header*, because that header names headers that are + themselves hop-by-hop. A port that hardcodes `HOP_BY_HOP` satisfies the sentence "strips + hop-by-hop headers" and is wrong. It also refuses to forward Tower's key headers, and this + port refuses the same ones. +- [ ] **Proxy failure splits into two signals**, as `client-static.ts` already does: the machine + refused the connection, versus it accepted and sent no response headers inside the bound. + One signal for both makes an unreachable host and a hung host look identical. +- [ ] **The approval record comes from the server, never from the browser.** + `approval.ts:300-316` refuses to fill `approvedAt`, `machine` and `sessionId` from local + state, calling that "the client telling a human their approval landed at the one moment it + has no business guessing". Criterion 4 asks porch to record exactly those three, so a port + that manufactures them locally passes a naive assertion while recording fiction. `sessionId` + stays nullable: an approval recorded before session ids existed is a real approval with an + unknown approver. +- [ ] Approval outcomes keep **four** states, not three and certainly not two: approved, + refused, **unconfirmed**, and **`sessionEnded`**. The first draft named three; the source + carries four (`approval.ts:79`, `:126`, `:135`). + - `unconfirmed` is a server answer the client could not read. The gate may well be + approved, so rendering it as a refusal sends a human to approve twice at the one point + where a duplicate costs something. + - `sessionEnded` is ordinary, not exceptional — sessions idle out after 30 minutes. Folding + it into refusal tells someone their approval failed when what they need is to re-open a + session. Same class of error as the one `unconfirmed` exists to prevent, one layer up. +- [ ] The ceremony is named in full, not glossed as "four requests": a pairing exchange + (`pairing/redeem`, spending a `machine-credential` token), then `openHumanSession` + (`approval.ts:152`) — a distinct single-use token exchange presenting through + `x-codev-human-session` — then capability issue and nonce mint, then the gate approval. +- [ ] **Pane content lands here too, at the architect's direction, and the fork's contract is + NOT extended for it.** Phase 9 shipped panes reading "Phase not read here yet — published + by codev-agent", which was the true sentence at the time. `codev-agent` has published the + porch phase and the last three messages workspace-scoped since phase 6 — + `GET /api/agent/v1/workspaces//state`, ONE request for the whole grid — and this + phase's proxy is the way to reach it. So the panes read it here rather than in a later + phase, and the plan records that because the file list below was written before the ruling. + + Three files beyond the list above: `apps/web/src/codev/agentState.ts` (the wire shape, + hand-mirrored and refused rather than half-read), `useCodevAgent.ts` (one poll for the whole + page), and `GateApproval.tsx` (kept out of `GatePanel.tsx` so the read-only panel stays a + pure function of the thread and its tests acquire no network). The project id the approval + needs comes from that same snapshot, which is the other reason the two arrived together: a + project id on the gate block would be a second copy of a fact `codev-agent` already owns. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 4**: a gate is approved from t3code and porch records the approving session id, + machine and timestamp in `status.yaml`, over `codev-agent`'s capability path. +- [ ] A caller without a machine credential, and one without a human session, are refused + differently — neither refusal is spelled like the other. +- [ ] `afx pair revoke ` stops that browser approving, and stops nothing else. + Verified against `packages/codev/src/agent-farm/commands/pair.ts:319-324`, which revokes + the credential and its live approval capabilities. +- [ ] `pnpm -w test` green; fork typecheck green and fork tests green for the server and web + suites this phase touches. + +#### Test Plan + +- Unit: proxy header handling **including a request whose `Connection` header names an extra + header**, which a fixed-list port forwards and a correct one strips; path allowlist; the refusal + for a path the table does not name; both proxy failure signals. +- Unit: the full approval walk, with a branch each for approved, refused, unconfirmed and + `sessionEnded`, and one asserting the record is server-sourced when the body is empty. +- Integration: approve a real gate end to end against a live `codev-agent` and assert + `status.yaml`. +- Adversarial: session without machine credential; machine credential from another machine; + revoked credential; an approval replayed after revocation. +- **SSRF**: an absolute URL in place of the allowlist id; a loopback address that is not the + configured one; a redirect from the configured origin to somewhere else; an unconfigured + internal target. Each refused, and refused server-side rather than by the page declining to + ask. +- Playwright: record every request the page issues while approving a gate and assert each one is + same-origin. This replaces the CSP assertion the first draft proposed, which would have passed + vacuously against a header t3code never sends. + +### Phase 11: Acceptance run — tailnet iPad and the rebase drill + +**Dependencies**: Phase 10 + +#### Objective + +Close the two criteria that only a run can close, and turn the rebase into a recorded procedure +rather than an event. + +#### Files to Create / Modify + +This repository: +- `tools/t3-server/collect-spec-250-evidence.mjs` — new, alongside the existing + `collect-phase10-evidence.mjs`. +- `tools/t3-codegen/REFRESH.md` — the drill's result recorded against the procedure. +- `tools/t3-fork/FORK.md` — the rebase entry. +- `packages/types/src/t3/pin.json` — `upstreamBase` and `commit` advanced by the drill. +- `packages/types/src/t3/generated/*` — regenerated after the rebase. +- `codev/resources/250-acceptance-evidence.md` — new. What was run, on what, with what result. +- `codev/reviews/250-t3code-front-end-customization.md` — the review. + +#### Deliverables + +- [ ] The rebase drill runs on a **THROWAWAY clone of upstream, and the real pin is unchanged.** + Rebase the fork's customization onto a later upstream commit in that scratch checkout, + regenerate the contract from it, pass `shape-check`, and pass `verify` on both identities + including the merge-base assertion — then throw the scratch away. + + **Amended 2026-08-31 at the architect's direction**, because the first wording ("rebase the + fork onto a later upstream commit named in `pin.json`") reads as an instruction to advance + `upstreamBase`, and doing that to satisfy a phase would spend the evidence base: + + - `/Users/chris/dev/t3code` is the **preserved** upstream clone at `082e6ea52186`, and it is + under a standing read-only order. A `git fetch` is fine — it adds objects and moves + remote-tracking refs while HEAD and the working tree stay put, which is what the order + actually protects. **A checkout is not.** + - The moment `pin.json` names a new base, `verify-upstream` expects the preserved clone to be + THERE, and every spec 146 and spec 236 result tied to `082e6ea52186` stops being + re-runnable. Advancing the base is a decision taken when there is a REASON — a security + fix, a feature we need — never as a phase deliverable. + + **Criterion 9 is met by the procedure completing and reporting**, not by adopting a new base. + A later reader who takes "rebases onto a later upstream commit" literally and moves the + preserved clone has broken the thing the criterion was measuring. +- [ ] Upstream churn measured as `oldUpstreamBase..newUpstreamTarget` in the **upstream** + checkout. This is the range that goes silent if nobody asks it, so it is the one asserted. +- [ ] A zero churn result is reported `NO_UPSTREAM_MOVEMENT` and **passes** — the pin was days old + and `classify-churn` counts only closure-touching commits, so a legitimate zero exists. The + tool failing, or reading the wrong ref, does not pass. Criterion 9 is satisfied by the + procedure running and reporting one of those three outcomes, never by an unexplained zero. + + Reaffirmed 2026-08-31. **Which of the three outcomes it was is reported explicitly**; an + unexplained zero is the only failing answer. As of 2026-08-31 upstream `main` is at + `9b2d04317c68` against our base `082e6ea52186` (read with `git ls-remote`, clone untouched), + so this run has real churn to measure and will not exercise the zero path. +- [ ] **The watermark is re-checked after the rebase**, which is the check that replaces the + first draft's "upstream must not have reached 900". Assert that every upstream migration + arriving with the rebase actually ran, by reading `effect_sql_migrations` and comparing it + to `migrationEntries`. "Upstream never reached our number" was the wrong invariant: under a + watermark migrator it is precisely the condition in which upstream's migrations get + skipped. +- [ ] Evidence file records the iPad run: device, network path, what was driven, what was seen. +- [ ] `apps/client` is confirmed **frozen and still green** — its tests pass, and this spec added + no front-end features to it. Spec 146's criteria 3, 4, 4b, 5, 7 and 8 are already-met facts + about it and are not re-verified. +- [ ] Tests for this phase. + +#### Acceptance Criteria + +- [ ] **Criterion 6**: the tree, the gate and the approval are reached from an iPad over the + tailnet — no account, no cloud relay — and a builder is driven to completion. + + **It closes one of two ways and never a third.** Either the run happens and it is met, or — + if no iPad is available — it closes **UNMET, with a stated reason and an executable runbook** + (`codev/resources/250-ipad-acceptance-runbook.md`). It does not close as passed on a + simulation, and it does not stay open. Ruled 2026-08-31. +- [ ] **Criterion 9** end to end, with the three-outcome churn report recorded. +- [ ] All eleven criteria (1, 2, 3, 4, 5, 5b, 6, 7, 8, 8b, 9, 10, 11) have a named test or a + recorded run. +- [ ] `pnpm -w test` green. + +#### Test Plan + +- Manual, recorded: the iPad run over the tailnet, start to finish, with the evidence collector + capturing what it can. +- Procedural: the rebase drill executed against `REFRESH.md`, with the doc corrected wherever the + run diverged from it. +- Regression: the full suite in both trees, plus `apps/client`'s. + +## Risks and Mitigation + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| **Churn detection goes blind** — a single range compares our tree to itself and reports no churn forever | High | High | Phase 1 splits it into two named ranges over two checkouts; phase 11 asserts the upstream range against a known-moved upstream | +| **`T3CODE_ROOT` stretched over two meanings** — one variable, six consumers, and `verify` against the upstream clone fails once `pin.commit` names the fork | High | High | Explicit per identity: `T3CODE_ROOT` stays upstream, `T3CODE_FORK_ROOT` is the fork, each with its own assertions | +| **Upstream migrations silently skipped** — `Migrator.js:121` skips any id `<= MAX(migration_id)`, so registering a high id shadows everything upstream adds later, while logging that the schema is current | Was High | Severe | Codev's columns never enter `migrationEntries`; a guarded idempotent `PRAGMA table_info` + `ALTER` runs at start and leaves the watermark alone. Phase 2 tests that a newly added upstream migration still runs; phase 11 re-checks after the rebase | +| **The guard's own partial application** — no migrator transaction wraps it, so a kill can land between two `ALTER`s | Medium | Low | Idempotent by construction: absent columns are added, present ones skipped, so the next start finishes the job. This is what makes criterion 8b discriminate rather than pass by construction | +| **Stale gate write recreates an approved gate** | Medium | High | Server-allocated high-water mark that survives the clear; criterion 10 delivers a stale revision after approval | +| **`codev-agent` restart resets a client-side counter** and silently renders every later gate as "none pending" | Medium | High | The counter is the server's, never the publisher's. Phase 4 makes this the mechanism, phase 6 asserts it across a restart | +| The generator's source-hash becomes a tautology once generation is fork-sourced | High | Medium | Phase 1 hashes the upstream closure at `upstreamBase` alongside the fork's | +| **A GitHub fork would publish the whole customization** — a fork inherits the source repo's visibility, so there is no private fork of a public repo | Was Certain | Severe | Ruled 2026-08-30: `gh repo create --private` with upstream as a remote, never `gh repo fork`. Visibility asserted after creation rather than assumed | +| MIT attribution lost in a private copy | Low | Medium | The `LICENSE` file and its attribution are carried unmodified; a phase-1 deliverable checks it | +| The fork's commits cannot appear in this repository's PR, leaving a reviewer with no diff | High | Medium | `pin.commit` names it, `FORK.md` logs it, phase 5 exports patches as a review aid | +| **`acquire` writes the fork SHA into the read-only upstream clone** once `pin.commit` moves — and `smoke.mjs` and `live/integration.mjs` both call it, so it fires from an ordinary test run | Was High | Severe | Phase 1 rewires `acquire`, `start` and `status` to `upstreamBase`, not `verify` alone | +| **A new RPC method that does not compile** — the authorization map is `satisfies Record` and `WsRpcMethod` derives from the unvendored `rpc.ts` | Was High | Medium | Phase 4 names all four registration points; phase 5 adds the method to `pin.methods`, following the `vcs.*` precedent | +| **The new scope leaks into the standard client set**, handing gate-writing to every ordinary client | Medium | Severe | Two named exclusions are deliverables: `AuthStandardClientScopes` and the `auth/http.ts` token allowlist. Gate commands also stay out of `ClientOrchestrationCommand` | +| **SSRF through the same-origin proxy** — a server-side proxy forwarding to a browser-named origin, which a path allowlist does not constrain | Medium | Severe | The target is chosen from a server-held allowlist by id; absolute URLs refused, redirects not followed, scheme and address rules enforced server-side | +| **Gate scope unenforceable at the existing authorization point** — `RpcAuthorization.ts:24` scopes the whole `dispatchCommand` method, so a gate command inside it inherits `orchestration:operate` | Was High | High | Gate writes get their own RPC method with its own row in the same scope map; a builder cannot reach the method at all | +| **A new project-map module lands dead** because `thread-backend.ts` already owns workspace-to-project resolution | Was High | Medium | Phase 6 extends `ensureThreadBackendReady`'s existing lookup-then-create path rather than adding a parallel one | +| **Our columns are invisible in upstream's migration history**, the accepted cost of staying out of the registry | Certain | Low | A named start-up log signal (`CODEV_SCHEMA_GUARD_APPLIED` / `_NOOP`) makes the fact observable rather than only inferrable from the schema | +| The fork becomes unmergeable | Medium | High | Keep the diff narrow: two record fields, one gate block, one scope, sidebar, tiling, proxy. No refactors of upstream code | +| Upstream security fix reaches us late — we now fork a server that executes shell commands | Medium | High | Criterion 9's cadence gets an owner and a maximum interval after the first rebase is measured | +| **t3code has no browser test tooling**, yet six criteria say "verified in t3code's own web app" and two are browser measurements | Was High | High | The Playwright harness lives in this repository (which already has `@playwright/test`) and drives the fork's dev server; the fork gains no test dependency and its diff stays narrow | +| Browser tests are gated on a running fork, so they can silently not run | Medium | High | A skip is reported as a skip and never counted as a pass — the same rule the spec-146 live suite already follows | +| Building and testing the fork is a large `pnpm` install on Node ^24.13.1, and the upstream clone currently has **no `node_modules` at all** — verified, not assumed | High | Medium | Fork install is done once in phase 1, before any phase depends on it; the Node advisory already recorded in the harness stands. Per-phase fork test runs are scoped to affected packages, with one full run at phase 11 | +| **Phase 1 breaks a passing test by design** — editing `t3-server.mjs` invalidates the cold-start evidence `spec-146-t3-contract.test.ts:254` guards | Certain | Low | Re-collecting the evidence against a live pinned server is a planned phase-1 step, not a surprise. The assertion is not loosened | +| **Phase 5 breaks another** — `:231` asserts `evidence.pinnedCommit === pin.commit`, and `pin.commit` becomes the fork head | Certain | Medium | Re-scoped to `upstreamBase`, because the evidence describes the upstream harness. Re-collecting against the fork would quietly change what it is evidence of | +| `apps/client` and t3code drift into two half-maintained clients | High | Low | `apps/client` is frozen — it keeps passing its tests and receives fixes; new front-end features land only in t3code | + +## Documentation Updates + +- `tools/t3-codegen/REFRESH.md` — the two-identity refresh and the rebase procedure (phases 1 + and 11). +- `tools/t3-fork/FORK.md` — new: **that `pseudoseed/t3code` is a private repo and not a GitHub + fork, and why** (a fork inherits the source's visibility, so `gh repo fork` would publish the + customization); the MIT licence obligation the private copy still carries; the `origin` and + `upstream` remotes, branch and checkout path; a **per-phase commit log** + (phases 2, 3, 4, 7, 8, 9 change only the fork, so this entry is their sole artifact in this + repository and what makes them committable here), the statement that the exported patches are a + review aid rather than the build mechanism, the **abandonment procedure** (revert `pin.commit` + to `upstreamBase`, regenerate, re-verify), and why migration ids in `migrationEntries` were + abandoned — so nobody reintroduces a high id later reasoning that a big gap is safe. +- `codev/resources/250-acceptance-evidence.md` — new: the criterion 6 and 9 runs. +- `codev/reviews/250-t3code-front-end-customization.md` — the review, at the end. +- `CLAUDE.md` and `AGENTS.md` — **only if** the fork becomes a requirement for working in this + repo. Byte-identical if touched. +- `codev/resources/arch.md` / `arch-critical.md` and `lessons-learned.md` / + `lessons-critical.md` — routed by tier at review time, not pre-committed here. Candidate facts: + the two-identity vendoring rule, and that porch gate state is a projection of `status.yaml` + with `status.yaml` authoritative. + +## Plan review rounds + +**Round 1, `claude` lane.** `REQUEST_CHANGES`, `HIGH` confidence. Every finding was verified +against source before being acted on, rather than taken as ground truth: + +| Finding | Verified against | Outcome | +|---|---|---| +| Migration 900 shadows all later upstream migrations | `effect@4.0.0-beta.103` `unstable/sql/Migrator.js:78`, `:121` — `ORDER BY migration_id DESC` then `if (currentId <= latestMigrationId) continue` | Confirmed. Mechanism changed to a guarded start-up applier outside `migrationEntries` | +| Phase 1 breaks `spec-146-t3-contract.test.ts:254` | Read the test — it compares evidence mtime against `t3-server.mjs` and `smoke.mjs` | Confirmed. Evidence re-collection is now a phase-1 deliverable | +| Phase 5 breaks `:231` | Read the test — `expect(evidence.pinnedCommit).toBe(pin.commit)` | Confirmed. Re-scoped to `upstreamBase` | +| Seven `T3CODE_ROOT` readers, not three | Grepped all of them, including `packages/t3-client/live/integration.mjs:77` which the first draft missed | Confirmed. All seven now assigned to an identity | +| `generate.mjs:78` root switch is load-bearing | Read `:70-85` — it refuses when checkout HEAD ≠ `pin.commit` | Confirmed and stated | +| Criterion 8b passed by construction | `Migrator.js:142` wraps the run in `sql.withTransaction` | Confirmed, and now moot: outside the migrator there is no wrapper, so the kill test discriminates | +| Phase 10 understates both ported modules | Read `client-static.ts:329-337` (dynamic `Connection` tokens) and `approval.ts:79/126/135` (`sessionEnded`) and `:300-316` (server-sourced record) | Confirmed. Four outcomes, dynamic stripping, two proxy signals, server-sourced record | +| Fork-only phases have no artifact here | — | Accepted. Per-phase `FORK.md` entry added to phases 2, 3, 4, 7, 8, 9 | +| No abandonment path | — | Accepted. Added to `FORK.md` in phase 5 | +| Fork suite scope unbounded | — | Accepted. Scoped per phase, one full run at phase 11 | + +**Round 1, `codex` lane** (added while the opencode lane was being diagnosed; kept, see the lane +note). `REQUEST_CHANGES`, +`HIGH` confidence, and additive to claude's rather than overlapping it — five findings, all +verified against source before being acted on: + +| Finding | Verified against | Outcome | +|---|---|---| +| Gate revision semantics not implementable: the plan said both "sends no revision" and "reject stale revisions" | Its own text, plus `commands.ts:310,321` for how a result returns | Confirmed contradiction. `revision` is now optional — absent means allocate, present means must exceed the mark | +| `codev:gate-write` unenforceable at the referenced point | `apps/server/src/auth/RpcAuthorization.ts:24` maps the whole `dispatchCommand` method to `orchestration:operate` | Confirmed. Gate writes get their own RPC method with its own row in that scope map | +| Phase 6's new project-map module would be dead code | `thread-backend.ts:442-450` already resolves projects by canonical workspace key; `:785-818` creates them | Confirmed. Phase 6 now extends `ensureThreadBackendReady` instead | +| Persistence work named too few modules | All four exist: `Services/ProjectionThreads.ts`, `Layers/ProjectionThreads.ts`, `Layers/ProjectionPipeline.ts`, `Layers/ProjectionSnapshotQuery.ts` | Confirmed. Named in phases 2 and 4, with start-up layer ordering asserted | +| Proxy has no upstream-target trust boundary — SSRF | — | Accepted. Server-held origin allowlist selected by id; absolute URLs refused, redirects not followed | + +**A second finding of my own: three phases planned tests with a tool the fork does not have.** +Phases 7, 8 and 9 named Playwright verification. t3code has no `playwright` in any +`package.json`, and `apps/web`'s whole test script is `vp test run --passWithNoTests --project +unit` on `@effect/vitest`. Criteria 5 and 5b are browser *measurements* — pane boxes in CSS px, +computed font size — so this was not a detail. The harness moved into this repository, which +already carries `@playwright/test ^1.58.0`, and drives the fork's dev server instead; the fork +gains no test dependency. + +**A third finding of my own, while verifying the above.** Phase 10 and the spec's Security section +both claimed `connect-src 'self'` "stays closed". Verified in the fork's tree: t3code sets +`Content-Security-Policy` on `.svg` asset responses only (`apps/server/src/http.ts:51,62`) and +`apps/web/index.html` has no CSP meta tag. There is no page-level CSP and no `connect-src` +directive to keep closed. The same-origin design is unchanged and still correct, but the guarantee +is structural rather than CSP-enforced, and the test now watches the network instead of parsing a +header that is never sent. + +**Round 1, `opencode` lane** (`xai/grok-4.6`). `REQUEST_CHANGES`, `HIGH` confidence, and additive +again — it found a hole in the fix made for codex's finding, which is the argument for three lanes +rather than two. All verified: + +| Finding | Verified against | Outcome | +|---|---|---| +| `codev.gateWrite` is never registered on the wire; a scope row alone is a type error | `RpcAuthorization.ts:130` is `satisfies Record`; `WsRpcMethod` comes from `WsRpcGroup` in the **unvendored** `rpc.ts` | Confirmed. Phase 4 now names all four registration points | +| Gate commands must stay out of the client command unions | `orchestration.ts:935-987` — those unions *are* the `dispatchCommand` payload; `ThreadSessionSetCommand` is the internal-only precedent | Confirmed. Otherwise `orchestration:operate` writes gates and the new scope is bypassed | +| Phase 5 would not vendor the method | `generate.mjs:335` iterates `pin.methods`, not `OrchestrationRpcSchemas` | Confirmed. Added to `pin.methods`, following the `vcs.*` precedent | +| **`acquire` still keys off `pin.commit`** and would check the fork SHA out into the read-only upstream clone | `t3-server.mjs:94` (`checkout --detach pin.commit` against `t3Root`), `:389`, `:663`; callers `smoke.mjs:156` and `live/integration.mjs:196` | Confirmed, and the most damaging item in either round. Phase 1 now rewires `acquire`, `start` and `status`, not `verify` alone | +| The gate-write credential path is unnamed despite the deliverable claiming otherwise | `auth.ts:84-93` closed literal, `:98-104` standard scopes, `auth/http.ts:265-274` allowlist | Confirmed. Path named, two exclusions made deliverables | +| Phase 4's revision return path was leftover text | — | Fixed: it returns on the new RPC, not `dispatchCommand` | + +**Lane note.** The `opencode` lane ran under a **non-default permission**. It auto-rejects +`external_directory` requests, and this plan cites `/Users/chris/dev/t3code` throughout, so two +runs died on their first outside read and exited `0` with no review file — a silent lane loss that +reads exactly like a review with nothing to say. It was re-run with +`OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"allow"}}'` scoped to the single +invocation, with no global config edited. Filed as issue #261. It auto-rejects +`external_directory` requests, and this plan cites `/Users/chris/dev/t3code` throughout, so the +first two runs died on their first outside read and exited `0` with no review file — a silent lane +loss that reads exactly like a review with nothing to say. Filed as issue #261. + +**The "exits 0 with no verdict" part of that was my own measurement error, and it is corrected +here rather than left standing.** Every failing run was invoked as `consult … 2>&1 | tail -15`, +and in a pipeline the reported exit code belongs to the *last* command — so the `0` was always +`tail`'s. Run with stdout and stderr redirected to files instead of piped, the same command +returns **exit code 1** and names the cause on stderr. The lane had been hard-failing correctly +all along, exactly as its own contract says it should (`commands/consult/index.ts:1693`; #20 +records why a lane that quietly produces nothing is worse than one that throws). + +With the permission granted *and* no pipe, the lane completed in 298s and wrote a full review. The +`codex` lane, substituted while this was being diagnosed, was kept — three independent reviews +rather than two, which earned its place: opencode found a hole in the fix made for codex's own +finding. + +**The porch-level case, which is worse than the one first filed.** A prefix on a `consult` command +covers only that invocation. When *porch* drives the consultation, the child inherits the +environment porch was started with, so a per-command prefix never reaches it: the child runs with +default permissions, dies on the first external-directory read, exits `0` with no verdict, and +porch — seeing the output file still missing — re-issues the identical task. The loop is +invisible, because every individual piece reports success. The form that works is an exported +variable the child inherits: + +```bash +export OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"allow"}}' +porch next 250 +``` + +**So this plan's opencode review, whenever it lands, ran under a non-default permission**, and a +reader should know that rather than assume a default lane produced it. The permission is broad — +it allows *any* external directory for that process, not only the t3code clone — and is accepted +here because the lane is read-only review on this machine. Both cases are on issue #261. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter1-rebuttals.md new file mode 100644 index 000000000..b4bc5b61d --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter1-rebuttals.md @@ -0,0 +1,110 @@ +# Spec 250, phase_1, iteration 1 — review responses + +Two lanes: **claude** (APPROVE) and **opencode / grok-4.6** (REQUEST_CHANGES). + +The opencode lane timed out at 360s on its first attempt and produced no verdict. `consult` has +no timeout flag — `OPENCODE_TIMEOUT_MS` is a hard-coded 6 minutes in +`packages/codev/src/commands/consult/index.ts:1561` — so the run was retried and completed. The +first failure was loud and exited 1, which is the lane behaving correctly; it is recorded here +rather than left as an unexplained gap. + +--- + +## opencode #1 — `ready()` still runs full `verify()` — ACCEPTED + +> `start()` only calls `verifyUpstream()` so a spec 146 run does not need the fork. `ready()` +> then calls `verify('CHECKOUT_MOVED_DURING_RUN')`, which requires fork HEAD == `pin.commit`. + +Correct, and it made `start()`'s upstream-only exemption buy nothing: `smoke.mjs` calls +`acquire`, `verify`, `start`, `ready` in sequence, so the fork requirement came back one call +later. `packages/t3-client/live/integration.mjs:202` had the same shape. + +The consequence is not hypothetical. Phases 2 through 4 commit to the fork while `pin.commit` +stays at `upstreamBase` until phase 5. On the first fork commit, a correct upstream server would +have failed `ready` with `CHECKOUT_MOVED_DURING_RUN` — a signal about the checkout the *server* +runs from, reported for a checkout the server never touches. + +**Fixed.** `ready()` now calls `verifyUpstream('CHECKOUT_MOVED_DURING_RUN')`. Two new subcommands, +`verify-upstream` and `verify-fork`, assert one identity each; `smoke.mjs` and +`live/integration.mjs` use `verify-upstream`. Bare `verify` still asserts both, which is what the +phase's acceptance criterion requires. + +Four tests: `verify-upstream` passes with no fork checkout at all, `verify-fork` passes with no +upstream root, bare `verify` still stops at `3` on a missing fork, and the three callers are +asserted to use the upstream-only verb. + +The cold-start evidence was re-collected after the `smoke.mjs` change. + +## opencode #2 — `verifyCheckout` treats a failed `git status` as clean — ACCEPTED + +> The catch comment says undetermined; the code returns success. Same "could not tell" as pass. + +Correct. The catch fell through to `dirty = ''`, and an empty string is how "clean" is spelled. +The comment claiming otherwise was inherited from the spec 146 version, which had the same bug — +the reviewer found it in the new file, and it was there before. + +**Fixed.** The catch now `die(UNDETERMINED, 'NO__STATUS: could not check: ...')`. + +The test triggers it for real rather than asserting on source: `chmod 000` on `.git/index` leaves +`rev-parse HEAD` working (it reads only the ref) and makes `git status` exit 128, which lands the +failure exactly between the two checks. The test refuses to pass vacuously — if the platform +ignores the mode, it fails with a message saying so rather than skipping quietly. + +## claude #1 — `FORK.md` overstates "nothing re-derives it" — ACCEPTED + +`packages/t3-client/live/integration.mjs` deliberately reads `process.env.T3CODE_ROOT` directly +and keeps it **required** (#214). The sentence was stronger than the code. + +**Fixed.** `FORK.md` names the exception and why it is one. + +## claude #2 — test heading says "the seventh readers" over six — ACCEPTED + +**Fixed.** Renamed to "the root readers". + +## claude — items it could not verify from its session + +The lane had no shell. All three are now checked: + +| Claim | Result | +|---|---| +| `pnpm -w test` | 7263 passed, 54 skipped, 0 failed, with both live suites executing | +| `gh repo view pseudoseed/t3code` | `visibility: PRIVATE`, `isFork: false`, default branch `codev` | +| MIT `LICENSE` unmodified in the fork | `diff` against the upstream clone reports identical | + +## Not changed + +**When `pin.commit` moves to the fork head.** Both lanes brushed against it; neither asked for a +change. The plan puts it at phase 5, so phases 2 through 4 will run with a fork checkout ahead of +`pin.commit` and bare `verify` will report `FORK_CHECKOUT_MISMATCH` in that window. That is the +plan's sequencing, not a phase 1 defect, and the per-identity verbs above mean it no longer blocks +an upstream server. Flagged to the architect rather than resolved here. + +--- + +# Iteration 2 review responses + +Both lanes APPROVE. claude raised two non-blocking notes; opencode raised none. + +## claude — `--since` bypassed the ref-resolution guard — ACCEPTED + +The guard ran over `range.from` before `--since` replaced it, so an unresolvable `--since` ref +slipped past and surfaced as a raw git error: exit 1 doing exit 3's job. + +**Fixed.** The guard now runs after `from` is computed, over the refs actually used. Tested with a +throwaway checkout and a `--since` naming a sha that does not exist. + +## claude — no direct test for the `NO_UPSTREAM_MOVEMENT` named zero — ACCEPTED + +Its `NO_FORK_DRIFT` twin was tested; the upstream one was not, because upstream has genuinely +moved on this machine (3 closure commits between `upstreamBase` and `origin/main`), so the real +pair cannot produce the zero. + +**Fixed.** The test builds a throwaway checkout whose `refs/remotes/origin/main` sits exactly where +the range starts. A real empty range, not a mocked one. + +## claude — items it could not verify without a shell + +`gh repo view pseudoseed/t3code --json visibility,isFork,nameWithOwner,defaultBranchRef` returns +`{"defaultBranchRef":{"name":"codev"},"isFork":false,"nameWithOwner":"pseudoseed/t3code","visibility":"PRIVATE"}`. +`pnpm -w test`: 7263 passed, 54 skipped, 0 failed, plus 180 in the v2 suite. Both re-run after +these two fixes. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter2-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter2-context.md new file mode 100644 index 000000000..e9b54f3e3 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_1-iter2-context.md @@ -0,0 +1,94 @@ +### Iteration 1 Reviews +- claude: APPROVE — Phase 1 two-identity harness is complete and correct; the destructive acquire path, merge-base check, and three-code exit semantics are all implemented and tested. +- opencode: REQUEST_CHANGES — ready/smoke/integration still run both-identity verify, so an upstream server start dies once the fork HEAD leaves pin.commit. + +### Builder Response to Iteration 1 +# Spec 250, phase_1, iteration 1 — review responses + +Two lanes: **claude** (APPROVE) and **opencode / grok-4.6** (REQUEST_CHANGES). + +The opencode lane timed out at 360s on its first attempt and produced no verdict. `consult` has +no timeout flag — `OPENCODE_TIMEOUT_MS` is a hard-coded 6 minutes in +`packages/codev/src/commands/consult/index.ts:1561` — so the run was retried and completed. The +first failure was loud and exited 1, which is the lane behaving correctly; it is recorded here +rather than left as an unexplained gap. + +--- + +## opencode #1 — `ready()` still runs full `verify()` — ACCEPTED + +> `start()` only calls `verifyUpstream()` so a spec 146 run does not need the fork. `ready()` +> then calls `verify('CHECKOUT_MOVED_DURING_RUN')`, which requires fork HEAD == `pin.commit`. + +Correct, and it made `start()`'s upstream-only exemption buy nothing: `smoke.mjs` calls +`acquire`, `verify`, `start`, `ready` in sequence, so the fork requirement came back one call +later. `packages/t3-client/live/integration.mjs:202` had the same shape. + +The consequence is not hypothetical. Phases 2 through 4 commit to the fork while `pin.commit` +stays at `upstreamBase` until phase 5. On the first fork commit, a correct upstream server would +have failed `ready` with `CHECKOUT_MOVED_DURING_RUN` — a signal about the checkout the *server* +runs from, reported for a checkout the server never touches. + +**Fixed.** `ready()` now calls `verifyUpstream('CHECKOUT_MOVED_DURING_RUN')`. Two new subcommands, +`verify-upstream` and `verify-fork`, assert one identity each; `smoke.mjs` and +`live/integration.mjs` use `verify-upstream`. Bare `verify` still asserts both, which is what the +phase's acceptance criterion requires. + +Four tests: `verify-upstream` passes with no fork checkout at all, `verify-fork` passes with no +upstream root, bare `verify` still stops at `3` on a missing fork, and the three callers are +asserted to use the upstream-only verb. + +The cold-start evidence was re-collected after the `smoke.mjs` change. + +## opencode #2 — `verifyCheckout` treats a failed `git status` as clean — ACCEPTED + +> The catch comment says undetermined; the code returns success. Same "could not tell" as pass. + +Correct. The catch fell through to `dirty = ''`, and an empty string is how "clean" is spelled. +The comment claiming otherwise was inherited from the spec 146 version, which had the same bug — +the reviewer found it in the new file, and it was there before. + +**Fixed.** The catch now `die(UNDETERMINED, 'NO__STATUS: could not check: ...')`. + +The test triggers it for real rather than asserting on source: `chmod 000` on `.git/index` leaves +`rev-parse HEAD` working (it reads only the ref) and makes `git status` exit 128, which lands the +failure exactly between the two checks. The test refuses to pass vacuously — if the platform +ignores the mode, it fails with a message saying so rather than skipping quietly. + +## claude #1 — `FORK.md` overstates "nothing re-derives it" — ACCEPTED + +`packages/t3-client/live/integration.mjs` deliberately reads `process.env.T3CODE_ROOT` directly +and keeps it **required** (#214). The sentence was stronger than the code. + +**Fixed.** `FORK.md` names the exception and why it is one. + +## claude #2 — test heading says "the seventh readers" over six — ACCEPTED + +**Fixed.** Renamed to "the root readers". + +## claude — items it could not verify from its session + +The lane had no shell. All three are now checked: + +| Claim | Result | +|---|---| +| `pnpm -w test` | 7263 passed, 54 skipped, 0 failed, with both live suites executing | +| `gh repo view pseudoseed/t3code` | `visibility: PRIVATE`, `isFork: false`, default branch `codev` | +| MIT `LICENSE` unmodified in the fork | `diff` against the upstream clone reports identical | + +## Not changed + +**When `pin.commit` moves to the fork head.** Both lanes brushed against it; neither asked for a +change. The plan puts it at phase 5, so phases 2 through 4 will run with a fork checkout ahead of +`pin.commit` and bare `verify` will report `FORK_CHECKOUT_MISMATCH` in that window. That is the +plan's sequencing, not a phase 1 defect, and the per-identity verbs above mean it no longer blocks +an upstream server. Flagged to the architect rather than resolved here. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter1-rebuttals.md new file mode 100644 index 000000000..863693da1 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter1-rebuttals.md @@ -0,0 +1,116 @@ +# Phase 10 — 3-way review, iteration 1 + +Two lanes. **Claude APPROVE / HIGH** (after a second pass that closed its own stated +coverage gap). **opencode REQUEST_CHANGES / HIGH.** The stricter lane is binding, and it +found the one defect that mattered. + +Every finding was accepted. Nothing is in a disagree column. + +--- + +## 1. The vitest e2e reported a PASS on a run that never happened — opencode, blocking + +> `spec-250-t3code-approval.e2e.test.ts` returns from `it()` when the fork is unavailable, so +> criterion 4 / SSRF at the wired handler go green without running. The file's own header says +> "skips, never passes". + +**Accepted, and it is the worst defect in the phase.** The guard was: + +```ts +function skipIfUnavailable(): boolean { + if (unavailable === null) return false; + console.warn(`SKIP spec-250 t3code approval: ${unavailable}`); + return true; // <- vitest records this as a PASS +} +``` + +So a run where the fork server never started reported **8 passed** with not one assertion +executed — on the phase's own acceptance criterion. That is this project's recurring defect +inverted: not "I could not tell" spelled as "no", but spelled as **"yes"**, which is strictly +worse. The file's header had the rule written in it and the code broke it; a header is not a +mechanism. + +Worse, it was invisible in every run I did, because the fork was always up. It would have +surfaced the first time someone ran the suite without `T3_NODE` — and it would have surfaced +as a green tick. + +**Fixed** with `ctx.skip(...)`, which marks the test skipped and does not return, so the body +is unreachable rather than merely unexecuted. The Playwright spec beside it already did this +with `test.skip`; the two now agree. + +**Demonstrated, not asserted.** Same file, same command, `T3_NODE` unset: + +``` +before: Tests 8 passed (8) +after: Tests 8 skipped (8) +``` + +and with the fork available, `Tests 8 passed (8)`. + +## 2. `UPSTREAM_TIMEOUT_MS` claimed more than the mechanism gives — Claude, non-blocking + +> applied via `upstream.setTimeout`, a Node idle-socket timeout, while the comment describes it +> as bounding "the whole exchange". + +**Accepted.** `ClientRequest.setTimeout` restarts its clock on socket activity, so it bounds +SILENCE, not elapsed time. The comment said otherwise, and overstating a bound is the same class +of error as the `connect-src 'self'` claim this phase existed to correct — it reads as protection +that is not there. + +The comment now says what the mechanism gives and states the residual explicitly: a trickling +upstream is not bounded by it. That upstream is one the operator named in +`T3CODE_CODEV_AGENT_ORIGINS`, so it is not a stranger, and a total-duration bound would have to +be large enough for the slowest legitimate answer — a worse trade for a threat the allowlist +already narrows to the operator's own hosts. Recorded rather than quietly accepted. + +## 3. `data-codev-approval-state` was coarser than its own words — both lanes + +> a session-ended outcome tags as `refused` in the machine-readable attribute while the visible +> text and testid distinguish it correctly. + +**Accepted**, and both lanes finding it independently is the signal. The attribute computed three +values over four outcomes. Nothing asserts on it today, which is exactly why it was worth fixing +now rather than later: **the first test written against it would have inherited the conflation +the file's own header exists to prevent** — "the session idled out" spelled the same as "your +approval was refused", one layer below where a human reads it. + +Four outcomes, four words, in an exported pure function (`approvalStateAttribute`) so the +attribute and the rendering cannot drift. Three tests, including one that pins the precedence +when an outcome carries both flags. Removing the `session-ended` branch fails two of them. + +## 4. Claude's own coverage gap, stated and then closed + +Claude's first pass said plainly which files it had not read — `agentState.ts`, +`useCodevAgent.ts`, `GateApproval.tsx`, `PairingPanel.tsx`, patches 0029-0031, the harness — and +rested its verdict on what it had read in full. Its second pass read them and raised confidence +from MEDIUM to HIGH. + +Worth recording because the honest declaration is what made the second pass targeted. A lane that +had said nothing would have produced the same verdict with no way to tell what it covered. + +## 5. A finding of my own, confirmed by the review + +Between the two lanes I found that the proxy buffered request bodies with **no bound** — +Effect's `MaxBodySize` defaults to unbounded, and this route reads the whole body before +forwarding. One authenticated caller could pin arbitrary memory on the route whose whole purpose +is to be reachable from a phone. + +Capped at 64 KiB; a declared oversize `content-length` is refused **before** the read, because +refusing after reading would already have done the thing the cap exists to prevent; a chunked +body declares no length, so the cap on the read catches that one. Too-large and could-not-read +get separate signals. + +Claude's second pass called it "a real availability fix". Verified by running the same test +against the fork commit before it (`e0476d49aec1`): it fails there and passes at `24aeeebb3ded`, +with no fork history touched. + +## What both lanes verified as holding + +Server-held origin allowlist with selection by id; `CODEV_AGENT_PATH_ABSOLUTE` for a URL in the +path; redirects refused rather than followed; unreachable and silent as distinct signals; +`Connection`'s own tokens subtracted from the header allowlist; `authorization` and `cookie` +never forwarded; the machine credential and the `client-session` token both required and refused +differently; four approval outcomes with the record server-sourced and an empty 200 rendering as +`unconfirmed` rather than a manufactured yes; pane content from one workspace-state poll rather +than six transcripts; and the Playwright spec recording every request and asserting same-origin, +with a positive assertion that the proxy was reached at all so the negative cannot pass vacuously. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-context.md new file mode 100644 index 000000000..f45ba2f20 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-context.md @@ -0,0 +1,130 @@ +### Iteration 1 Reviews +- claude: APPROVE — Phase 10 delivers the same-origin proxy, the full pairing/approval ceremony and a server-sourced approval record, with the SSRF boundary, header allowlist and four approval outcomes each held up by a test that can fail. +- opencode: REQUEST_CHANGES — Phase 10's proxy, pairing, four-outcome approval, and Playwright same-origin watch match the plan; the vitest e2e still pass-skips when the fork is down. + +### Builder Response to Iteration 1 +# Phase 10 — 3-way review, iteration 1 + +Two lanes. **Claude APPROVE / HIGH** (after a second pass that closed its own stated +coverage gap). **opencode REQUEST_CHANGES / HIGH.** The stricter lane is binding, and it +found the one defect that mattered. + +Every finding was accepted. Nothing is in a disagree column. + +--- + +## 1. The vitest e2e reported a PASS on a run that never happened — opencode, blocking + +> `spec-250-t3code-approval.e2e.test.ts` returns from `it()` when the fork is unavailable, so +> criterion 4 / SSRF at the wired handler go green without running. The file's own header says +> "skips, never passes". + +**Accepted, and it is the worst defect in the phase.** The guard was: + +```ts +function skipIfUnavailable(): boolean { + if (unavailable === null) return false; + console.warn(`SKIP spec-250 t3code approval: ${unavailable}`); + return true; // <- vitest records this as a PASS +} +``` + +So a run where the fork server never started reported **8 passed** with not one assertion +executed — on the phase's own acceptance criterion. That is this project's recurring defect +inverted: not "I could not tell" spelled as "no", but spelled as **"yes"**, which is strictly +worse. The file's header had the rule written in it and the code broke it; a header is not a +mechanism. + +Worse, it was invisible in every run I did, because the fork was always up. It would have +surfaced the first time someone ran the suite without `T3_NODE` — and it would have surfaced +as a green tick. + +**Fixed** with `ctx.skip(...)`, which marks the test skipped and does not return, so the body +is unreachable rather than merely unexecuted. The Playwright spec beside it already did this +with `test.skip`; the two now agree. + +**Demonstrated, not asserted.** Same file, same command, `T3_NODE` unset: + +``` +before: Tests 8 passed (8) +after: Tests 8 skipped (8) +``` + +and with the fork available, `Tests 8 passed (8)`. + +## 2. `UPSTREAM_TIMEOUT_MS` claimed more than the mechanism gives — Claude, non-blocking + +> applied via `upstream.setTimeout`, a Node idle-socket timeout, while the comment describes it +> as bounding "the whole exchange". + +**Accepted.** `ClientRequest.setTimeout` restarts its clock on socket activity, so it bounds +SILENCE, not elapsed time. The comment said otherwise, and overstating a bound is the same class +of error as the `connect-src 'self'` claim this phase existed to correct — it reads as protection +that is not there. + +The comment now says what the mechanism gives and states the residual explicitly: a trickling +upstream is not bounded by it. That upstream is one the operator named in +`T3CODE_CODEV_AGENT_ORIGINS`, so it is not a stranger, and a total-duration bound would have to +be large enough for the slowest legitimate answer — a worse trade for a threat the allowlist +already narrows to the operator's own hosts. Recorded rather than quietly accepted. + +## 3. `data-codev-approval-state` was coarser than its own words — both lanes + +> a session-ended outcome tags as `refused` in the machine-readable attribute while the visible +> text and testid distinguish it correctly. + +**Accepted**, and both lanes finding it independently is the signal. The attribute computed three +values over four outcomes. Nothing asserts on it today, which is exactly why it was worth fixing +now rather than later: **the first test written against it would have inherited the conflation +the file's own header exists to prevent** — "the session idled out" spelled the same as "your +approval was refused", one layer below where a human reads it. + +Four outcomes, four words, in an exported pure function (`approvalStateAttribute`) so the +attribute and the rendering cannot drift. Three tests, including one that pins the precedence +when an outcome carries both flags. Removing the `session-ended` branch fails two of them. + +## 4. Claude's own coverage gap, stated and then closed + +Claude's first pass said plainly which files it had not read — `agentState.ts`, +`useCodevAgent.ts`, `GateApproval.tsx`, `PairingPanel.tsx`, patches 0029-0031, the harness — and +rested its verdict on what it had read in full. Its second pass read them and raised confidence +from MEDIUM to HIGH. + +Worth recording because the honest declaration is what made the second pass targeted. A lane that +had said nothing would have produced the same verdict with no way to tell what it covered. + +## 5. A finding of my own, confirmed by the review + +Between the two lanes I found that the proxy buffered request bodies with **no bound** — +Effect's `MaxBodySize` defaults to unbounded, and this route reads the whole body before +forwarding. One authenticated caller could pin arbitrary memory on the route whose whole purpose +is to be reachable from a phone. + +Capped at 64 KiB; a declared oversize `content-length` is refused **before** the read, because +refusing after reading would already have done the thing the cap exists to prevent; a chunked +body declares no length, so the cap on the read catches that one. Too-large and could-not-read +get separate signals. + +Claude's second pass called it "a real availability fix". Verified by running the same test +against the fork commit before it (`e0476d49aec1`): it fails there and passes at `24aeeebb3ded`, +with no fork history touched. + +## What both lanes verified as holding + +Server-held origin allowlist with selection by id; `CODEV_AGENT_PATH_ABSOLUTE` for a URL in the +path; redirects refused rather than followed; unreachable and silent as distinct signals; +`Connection`'s own tokens subtracted from the header allowlist; `authorization` and `cookie` +never forwarded; the machine credential and the `client-session` token both required and refused +differently; four approval outcomes with the record server-sourced and an empty 200 rendering as +`unconfirmed` rather than a manufactured yes; pane content from one workspace-state poll rather +than six transcripts; and the Playwright spec recording every request and asserting same-origin, +with a positive assertion that the proxy was reached at all so the negative cannot pass vacuously. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-rebuttals.md new file mode 100644 index 000000000..3a72c2af6 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter2-rebuttals.md @@ -0,0 +1,64 @@ +# Phase 10 — 3-way review, iteration 2 + +**Claude COMMENT / HIGH. opencode APPROVE / HIGH, no issues.** opencode verified all three +iteration-1 fixes in the tree rather than taking the rebuttals' word for them. + +One finding, accepted, and it is a good one. + +--- + +## 1. The same-origin assertion was a PREFIX match — Claude + +> `spec-250-approval.spec.ts:202,292` compare origins with `url.startsWith(origin)`; the agent +> host's ephemeral port can prefix-match `http://localhost:5733` (57330-57339), so a real +> cross-origin request would be filtered as same-origin. + +**Accepted, with the numbers, because the numbers are why it is worth fixing rather than noting.** + +`webAppUrl()` defaults to a fixed `http://localhost:5733` and the agent host binds an ephemeral +port through `listen(0)`. So `http://localhost:57330` … `:57339` prefix-match — ten ports inside +macOS's ephemeral range of 49152-65535, about **0.06% of runs** in which a genuinely direct +browser-to-agent request would have been counted as same-origin and the phase's central security +assertion would have passed anyway. + +**A rare false pass is worse than a common one.** It makes the test look reliable while it is not, +and 0.06% is precisely the rate at which nobody ever sees it fail — so it would have been trusted +for the life of the spec. This is the same family as iteration 1's skip-as-pass, one layer in: not +a test that could not run, but a test that could run and could not fail. + +**Fixed** by comparing parsed origins, and **the predicate moved out of the Playwright spec into +`spec-250-same-origin.ts` so it can be tested at all.** That relocation is the more durable half of +the fix: the function that decides whether a security claim passed was the one piece of the suite +with no test of its own, which is how it stayed wrong. + +Five unit tests, in the default suite. Restoring the prefix match fails **three** of them — +including the 57330-57339 case, stated as the concrete ports rather than as a principle. + +## 2. `blob:` alongside `data:` — Claude, non-blocking + +> `blob:` is not exempted alongside `data:` in the same filter, which would produce a false failure +> rather than a false pass. Harmless today. + +**Accepted, and generalised rather than patched.** Naming `blob:` beside `data:` leaves `about:` +and whatever a browser invents next to break a later run. Non-http schemes are now exempt as a +**class** — `if (!/^https?:/i.test(url)) return false` — because none of them is a request to +another origin, and `new URL("data:…").origin` is the string `"null"`, so comparing them by origin +is what would produce the false failure. + +One of the five tests covers `data:`, `blob:` and `about:` together. + +## What opencode verified rather than assumed + +It read the iteration-1 fixes in the code, not in the rebuttals: `skipIfUnavailable` calls +`ctx.skip` (typed `never` on Vitest 4, so the body cannot run), `UPSTREAM_TIMEOUT_MS` is documented +as an idle-socket timeout, and `approvalStateAttribute` maps four outcomes to four words. It also +confirmed the proxy is registered in `server.ts` beside the targets route — the wiring, not just +the module. + +## Everything else, both lanes + +Origins from `T3CODE_CODEV_AGENT_ORIGINS` with the browser selecting by id; absolute paths, unknown +targets, uncarried paths and redirects refused; `Connection` tokens subtracted from the header +allowlist; `authorization` and `cookie` never forwarded; the ceremony unchanged and the record +server-sourced with an empty 200 as `unconfirmed`; pane content from one workspace-state poll; and +the Playwright same-origin watch with a positive proxy hit so the negative cannot pass vacuously. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter1-rebuttals.md new file mode 100644 index 000000000..c2a925719 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter1-rebuttals.md @@ -0,0 +1,129 @@ +# Spec 250 — phase 11, iteration 1 rebuttals + +Lanes: **claude REQUEST_CHANGES / HIGH**, **opencode COMMENT / HIGH**. They found the same defect +and disagreed only on severity, so the stricter reading is the one acted on. Nothing here is +deferred. + +## 1. `ok` claimed the contract regenerated and shape-check held. BINDING — fixed. + +**Both lanes, and they are right.** The header defined the outcome vocabulary as: + +``` +ok rebase clean, contract regenerated, shape-check held +regenerate-failed rebase landed, the generator did not +shape-check-failed both landed, the contract does not match +``` + +The clean-rebase branch rev-parsed and rev-listed. It never called the generator and never ran +`shape-check`, and `regenerate-failed` / `shape-check-failed` were assigned nowhere in the file — +two documented states that could not occur. An operator reading `"outcome": "ok"` in a future run's +JSON would be told shape-check held on the strength of a comment. + +This is the third finding of the same shape in this project, and the first two are named in the +phase 10 record: a guard that logged and returned (vitest counts that as a pass), and a +`startsWith` same-origin assertion that could not fail on ephemeral ports. This one sits on the tool +whose stated subject is that "I could not tell" must never be spelled like "no". + +**It is not closable by calling the generator.** `generate.mjs` refuses any checkout whose `HEAD` is +not `pin.commit`, and no rebased tree satisfies that — its head is a commit that did not exist +before the rebase. Regenerating from one means moving the pin, which is the adoption the drill +exists in order *not* to perform; it is step 3 of `tools/t3-codegen/REFRESH.md`, taken when a rebase +is adopted for a reason. So the reviewer's option 1 is unreachable without changing what the drill +is, and stopping at option 2 — a comment edit — would leave criterion 9 answered by a proxy nobody +had measured. + +Three changes instead: + +- **The vocabulary is now `ok` | `conflicts` | `could-not-run`**, which is exactly the set the file + assigns. `ok` says "every customization commit replayed with no conflicts", and its `detail` + string adds that the contract was not regenerated and shape-check did not run. +- **`contractRegeneration.attempted: false`, with the reason, in every result.** An absent field + reads as nobody having considered it; this is a stated refusal a reader can quote. +- **`contractClosure.sourceHash` is the measurement that replaces the claim.** It hashes the closure + off the merged tree, sha256 per file the way `generate.mjs` hashes it, and compares to + `generated/source-hash.json` — the layer `generate.mjs` itself names as load-bearing, because the + emitted schema is blind to constraints behind a `decodeTo` transform. + +**That measurement changed the answer, which is the point of taking it.** The closure merges with +zero conflicts, so regeneration is not *blocked* — but **4 of the 9 closure files come out of the +merge with different bytes** (`auth.ts`, `baseSchemas.ts`, `environment.ts`, `orchestration.ts`), so +the regenerated contract would not be the vendored one. "Regenerable" and "unchanged" had been +reading as one fact. + +### Proving the new checks can fail + +Per the standing order, each mechanism was reverted and the test re-run. + +| Reverted | Result | +|---|---| +| Re-added `shape-check-failed` to the documented list | `documents exactly the outcomes it can assign` **fails** | +| Removed `contractRegeneration` from the evidence | `records that regeneration and shape-check did not run` **fails** | +| Set `sourceHash.moved` to `[]` | `measures the closure off the merged tree` **fails** | + +**The ordering inside the drill is itself load-bearing, and that was checked separately.** The hash +must be taken while the merged worktree is on disk, *before* `merge --abort`. Taken after the abort +the worktree is the fork again and the comparison is the fork against itself. Hashing the unmerged +fork against `generated/source-hash.json` directly reports `moved: []` — so the post-abort version +of this measurement is a tautology that passes on every run regardless of what upstream did. The +test asserts a non-empty `moved` whenever `upstreamChurn.closureTouching > 0`, which fails on that +tautology. + +### One more door into the same tautology, found while writing the above + +Neither lane raised this; it turned up re-reading my own fix. The hash is guarded on +`closureConflicts.length === 0`, which is the right question **once a merge has happened**. A +`git merge` that refuses to start at all — already up to date, a wedged index — leaves the worktree +as the unmerged fork with no conflicts to notice, and the guard would wave it through into exactly +the fork-against-itself comparison the ordering exists to avoid. + +`mergeProducedATree` (`merge.ok || conflictedList.length > 0`) is now the outer condition. No merged +tree means no measurement, reported as `checked: false` carrying what git said. + +## 2. The criterion 9 `shape-check` row describes the current pin. Fixed. + +`| shape-check | generate.mjs --check → artifacts are up to date |` was true of `3786b840e1a4` and +sat in a table about the rebased tree. Split into two rows that cannot be read as each other: +`shape-check` **at the current pin** (what was run) and `shape-check` **on the rebased tree** (did +not run, with the reason). The generated block carries the same fact as a row of its own, so it +survives regeneration. + +A new subsection, "What the drill does not do, and why that is stated rather than implied", carries +the full account in `codev/resources/250-acceptance-evidence.md`. + +## 3. Churn `104 / 5` was hand-typed. Fixed. + +**opencode is right that this is exactly what the collector was built to stop.** The drill now +counts both from the preserved clone over the same range it rebased across — so the churn and the +conflict surface can never describe two different ranges — and the collector prints them. +`null` (could not count) renders as `**not counted**`, never as `0`. + +The counted values are **104** and **5**, matching what was typed. The verdict split (3 `source-only`, +2 `consumed-change-undecidable`) stays prose: it comes from `classify-churn --upstream-movement`, +which is a separate run and is cited as one. + +## 4. The regression run excluded `**/e2e/**`, so criteria 1, 2, 3, 5, 5b rest on phase 7-10 runs. + +Non-blocking in the claude lane, not raised by opencode, and **actioned anyway** — this is the last +phase before the PR gate, and standing order 11 makes a run the thing that backs those criteria. + +Worth stating precisely first: **phase 11 adds no fork commit.** `pin.commit` is `3786b840e1a4`, +which is phase 10's head, so the phase 7-10 Playwright runs were already runs at the final fork +head. What changed since is codev-side only — tools, docs, and the frozen `apps/client` suite — +none of which the spec-250 specs load. The re-run is confirmation, not a correction. + +**Result: 32 passed in 2.3m**, all four spec files, against the running fork web app at +`3786b840e1a4`. Recorded in the acceptance evidence's regression table as its own row. + +**The first attempt reported `32 skipped` and exited 0** — `T3_NODE` was unset, and the fixture +refuses to start the fork server without it. That is phase 10's skip-with-a-reason working as built, +and it is the reason this row says "32 passed" rather than "the suite is green": a run that exits 0 +having executed nothing is the failure mode this whole phase is about. + +## Not changed, and why + +**The `ok` branch still does not regenerate.** Making `regenerate-failed` and `shape-check-failed` +reachable would mean giving `generate.mjs` a way to accept a checkout that is not at `pin.commit`. +That guard is the reason vendored artifacts are reproducible, and loosening it so a drill can +exercise two outcome strings would trade a real invariant for a label. The drill measures the +generator's inputs instead and says, in the JSON and in the header, that it did not run the +generator. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-context.md new file mode 100644 index 000000000..e05bbc276 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-context.md @@ -0,0 +1,97 @@ +# Phase 11, iteration 2 — what changed and what to look at + +Iteration 1: **claude REQUEST_CHANGES / HIGH**, **opencode COMMENT / HIGH**. One defect, found by +both. The stricter reading was taken as binding. Full response in +`250-phase_11-iter1-rebuttals.md`. + +## The binding finding + +`tools/t3-fork/rebase-drill.mjs`'s header defined the outcome vocabulary as `ok` = "rebase clean, +contract regenerated, shape-check held", with `regenerate-failed` and `shape-check-failed` beside +it. The clean-rebase branch rev-parsed and rev-listed. It called neither tool, and neither of those +two outcomes was assigned anywhere in the file. + +**The reviewer's cheap option is unreachable.** `generate.mjs` refuses any checkout whose `HEAD` is +not `pin.commit`, and a rebased tree never satisfies that — its head is a commit that did not exist +before the rebase. So regenerating from one means moving the pin, which is the adoption this drill +exists in order not to perform. + +## What was changed + +1. **Vocabulary narrowed to `ok` | `conflicts` | `could-not-run`**, which is exactly the set the file + assigns. `ok` now means "every customization commit replayed with no conflicts", and its `detail` + says the contract was not regenerated and shape-check did not run. +2. **`contractRegeneration.attempted: false`, with the reason, in every result.** An absent field + reads as nobody having considered it. +3. **`contractClosure.sourceHash`** — the closure hashed off the merged tree (sha256 per file, the + way `generate.mjs` hashes it) and compared to `generated/source-hash.json`. That is the layer + `generate.mjs` argues is the load-bearing drift detector, because the emitted schema is blind to + constraints behind a `decodeTo` transform. +4. **Churn counted, not typed.** `upstreamChurn.commits` / `.closureTouching` from the preserved + clone over the same range the drill rebased across. `null` (could not count) is rendered + `**not counted**`, never `0`. +5. **The evidence, `FORK.md` and `REFRESH.md`** corrected in three places where "regenerable" and + "unchanged" were reading as one fact. +6. **Criterion 9's status** changed from a bare "met" to "met under the plan's amended reading", with + a table setting its four clauses against what was actually run. + +## The new fact this produced + +Zero closure conflicts — so regeneration is **not blocked** — but **4 of the 9 closure files come +out of the merge with different bytes** (`auth.ts`, `baseSchemas.ts`, `environment.ts`, +`orchestration.ts`), so the regenerated contract would **not** be the one vendored. + +## Falsifiability — please attack these specifically + +Each new assertion was verified by reverting its mechanism and confirming failure: + +| Reverted | Test that failed | +|---|---| +| Re-added `shape-check-failed` to the documented list | `documents exactly the outcomes it can assign, and no others` | +| Removed `contractRegeneration` from the evidence | `records that regeneration and shape-check did not run` | +| Set `sourceHash.moved` to `[]` | `measures the closure off the merged tree` | + +**The ordering inside `closureSourceHash` is the load-bearing part.** The hash is taken while the +merged worktree is on disk, before `merge --abort`. Taken after, the worktree is the fork again and +the comparison is the fork against itself; hashing the unmerged fork directly reports `moved: []`, +which is what that tautology would publish on every run. + +**A second door into the same tautology was found and closed** without either lane raising it: a +`git merge` that refuses to start leaves the worktree unmerged with no conflicts to notice, and a +guard that only asks "did the closure conflict" is vacuously satisfied there. + +That decision now lives in `tools/t3-fork/drill-closure.mjs` as `closureMeasurability`, with **5 unit +tests**. It was extracted rather than left inline because `rebase-drill.mjs` is a script — importing +it runs a drill against two real checkouts — so an inline guard is covered only by whatever branch +the last real run happened to take, which is the wrong coverage for a guard that exists to fire on +cases no normal run reaches. Deleting the guard fails 2 of the 5. + +**One test in that file was written and then deleted, and the deletion is the part to check.** It +asserted that the no-merge check runs before the closure-conflict check; swapping the two in the +module left it passing. `closureConflicts` is a subset of `conflictedFiles`, so a non-empty closure +conflict implies a non-empty conflict list, and the no-merge branch requires that list to be empty — +the two cannot both hold for well-formed input, so there is no order to assert. A comment in the test +file records that. **Tell me if you think the deletion was wrong.** + +## The non-blocking note, actioned + +Phase 11's regression run excluded `**/e2e/**`. The spec-250 Playwright suites were re-run at the +fork head: **32 passed** in 2.3m. The first attempt reported `32 skipped` and exit 0 because +`T3_NODE` was unset — the fixture refusing to start the fork server, which is phase 10's +skip-with-a-reason working as built. + +## Runs behind this iteration + +- `npm test -- --exclude='**/e2e/**'`: **7387 + 180 passed, 57 skipped, 0 failed**, exit 0 +- `porch done` checks: build 16.6s ✓, tests 196.1s ✓ +- drill re-run from the committed code; `collect-spec-250-evidence.mjs --check` exit 0 +- the three phase 11 test files: **20 passed** +- fork clean at `3786b840e1a4`, upstream clean at `082e6ea52186` + +## Out of scope, deliberately + +- **Making the drill regenerate.** It would require loosening `generate.mjs`'s `HEAD === pin.commit` + guard, which is why vendored artifacts are reproducible. Reasoned in the rebuttal's last section. +- **Criterion 6 (the iPad).** Closes UNMET with a runbook; no device. +- **Issue #264.** Filed, not fixed here — it is porch/tower, and folding it in would put an unrelated + change in a fork PR. Its second occurrence happened during this iteration and is recorded there. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-rebuttals.md new file mode 100644 index 000000000..13b7fdf03 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_11-iter2-rebuttals.md @@ -0,0 +1,93 @@ +# Spec 250 — phase 11, iteration 2 rebuttals + +**claude APPROVE / HIGH. opencode COMMENT / HIGH. Nothing blocking.** Four notes between them, all +actioned rather than acknowledged. + +Both lanes were also asked directly whether deleting the order test was right, and both said yes for +the same reason: `closureConflicts` is built by filtering `conflictedFiles` at the call site, so the +two branch predicates are mutually exclusive and no well-formed input distinguishes the orderings. +claude added a caveat worth recording — the subset relation is a **caller obligation** the exported +function does not enforce, and `{conflictedFiles: [], closureConflicts: ['x']}` would make order +observable. That input is malformed and no caller produces it. Left as it is; the comment already +says "for well-formed input". + +## 1. My own tests would have failed a correct zero-movement drill. Fixed. + +**claude, and it is the sharpest finding of the two iterations because it is the same defect +inverted.** The drill produces one of two shapes, and `NO_UPSTREAM_MOVEMENT` is a **pass**: with +upstream still at our base there are no new migrations to shadow and no merged tree to hash, so that +result legitimately carries `watermark.checked: false`, `contractClosure.checked: false`, zero churn +and no `preserved` block. + +The suite asserted the other shape unconditionally. Three assertions would have failed and a fourth +would have thrown, on a run that was entirely correct. Two iterations were spent on tests that pass +when they should fail; this is a test that fails when it should pass, and it is the same missing +question — *which claim is this artifact actually making?* + +The shape is now named once (`zeroMovement`), each branch asserts its own contract, and a test +asserts the two ways of detecting the shape agree with each other. **Verified by running the suite +against a synthetic zero-movement evidence file: 6 passed, 7 skipped.** Against the old suite that +same file failed 3 and threw on 1. + +`it (...) { if (x) return; }` in the whole-surface test became `it.runIf(x)(...)` while I was there. +A `return` inside a test body is recorded by vitest as a pass with zero assertions — which is +literally the phase 10 finding, still sitting in a file I wrote. + +## 2. A comment outlived the test it described by one commit. Fixed. + +**opencode.** The header of `spec-250-drill-closure.test.ts` still said "the order of the two checks +is asserted below", one commit after that test was deleted for being unfalsifiable. The accurate +comment was at the bottom of the same file, contradicting it. + +Iteration 1's finding was a header claiming a check the code did not perform. This is the same thing, +one file over, introduced by the fix for it. The header now states plainly that the order is **not** +asserted and points at the comment that explains why. + +opencode also caught the phase 11 review still naming `mergeProducedATree`, the inline predicate that +became `closureMeasurability` when it was extracted. Corrected, with the reason for the extraction. + +## 3. The churn classification was hand-typed prose. Fixed. + +**claude, and it is the same argument the collector was built on.** "3 `source-only`, 2 +`consumed-change-undecidable`" sat outside the marker block in both +`250-acceptance-evidence.md` and `REFRESH.md`. I had defended keeping it as prose on the grounds that +it comes from a separate run — which is a description of the problem, not a reason. + +`classify-churn --upstream-movement` is now persisted to +`codev/research/250-upstream-movement.json` and printed into the generated block. Its counts +(3 and 2) match what had been typed, which is the outcome that makes this worth doing: the fix was +not prompted by a wrong number, and the next drill is where a typed one goes wrong silently. + +Two refusals came with it, both exit 3, both falsifiable by deleting the guard: + +- **A classification covering a different range than the drill.** Every cell in the table would be + individually correct while the table as a whole paired a closure-touching count with a conflict + surface measured over a different span. Two ranges in one table is worse than one range and a gap. +- **A classification run against the fork.** `classify-churn --fork-drift` emits the same JSON shape, + and the fork answering "what did upstream change" reports our own work back to us. + +The range is tied by base plus **ref name**, because that is what `classify-churn` records. An +`origin/main` that moved between the two runs would slip through. Stated in the code rather than +papered over: the drill's target sha is printed beside it, and both runs belong to the same refresh +step. + +## 4. `contractRegeneration` was not in "every result". Fixed by correcting the claim. + +**opencode.** Three documents said "every result"; the three early `could-not-run` paths do not carry +it. Corrected the wording rather than padding the field into those paths. **`could-not-run` means +nothing was learned**, and a measurement-shaped field on that document is the first thing a reader +would mistake for a finding. Its `reason` is the whole document, and that is deliberate. + +## Not changed, and why + +**`spec-250-evidence-collector.test.ts` still mutates committed files and restores them in +`finally`.** claude flagged that a hard kill mid-test leaves the repository dirty. Removing the +hazard means giving the collector path overrides for its four inputs and its output, which widens a +tool's interface to suit a test — and the test's whole value is that it drives the collector against +its **real** inputs. The `withRestored` helper is explicit about what it does and the blast radius is +one `git checkout` of two files. + +**The drill still does not regenerate the contract.** Unchanged from iteration 1: `generate.mjs` +refuses any checkout whose `HEAD` is not `pin.commit`, so doing it means moving the pin. claude +confirmed the reason is documented on every result path of a drill that ran, including the zero-churn +early return. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter1-rebuttals.md new file mode 100644 index 000000000..aaccca60a --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter1-rebuttals.md @@ -0,0 +1,96 @@ +# Spec 250, phase_2, iteration 1 — review responses + +**claude** REQUEST_CHANGES, **opencode** COMMENT. Three findings, all accepted, none disputed. +Both lanes named the two substitutions independently. + +--- + +## claude #1 / opencode — "a newly introduced upstream migration still runs" never invoked the migrator — ACCEPTED + +> `schemaGuard.test.ts:145` never invokes the migrator, so "a newly introduced upstream migration +> still runs after the guard" is unproven. + +Correct, and the citation is exact: `:155` was a raw +`ALTER TABLE projection_threads ADD COLUMN pretend_upstream_column TEXT`. + +That statement proves SQLite accepts another column. It proves nothing about whether the +**watermark** let the migrator execute one — which is the entire question migration 900 got wrong, +and therefore the entire reason the test exists. The substitution was adjacent enough to the real +mechanism to produce identical observable state: a column appeared either way. Only the path it +appeared by was ever in question, and that is exactly what the assertion had dropped. + +**Fixed.** `runMigrations({ toMigrationInclusive: 41 })` → `applyCodevSchemaGuard()` → +`runMigrations()`, asserting migration 42 executed *and* that its column exists — recorded-as-run +and actually-applied being two claims. That is upstream's own idiom from +`042_ProjectionThreadLinkedPullRequest.test.ts:16-17`, as the lane pointed out. + +Added alongside it: a test that the guard writes **no rows at all** to `effect_sql_migrations`, so +every id above 41 stays free for upstream. + +## claude #2 / opencode — criterion 8b was simulated, not exercised — ACCEPTED + +> No process kill, no file-backed DB, nothing opened against the pinned pre-fork server. Exercise +> it or record the substitution as a deviation. + +Correct. The criterion names four things — a kill, a partial application, a resulting database, and +the pre-fork server binary — and the test had one of them. It still passed. + +**Exercised, not recorded as a deviation.** `tools/t3-fork/criterion-8b.mjs` runs the real sequence +and writes `codev/research/250-criterion-8b-evidence.json`: + +| Step | Result | +|---|---| +| Pinned `t3@0.0.36` creates and migrates a real database | opened and answered | +| Codev columns present beforehand | none — otherwise the run proves nothing | +| Child SIGKILLed after the first `ALTER` | `SIGKILL`; `codev_role` alone on disk | +| **Pre-fork binary opens the half-applied file** | **opened and answered** | +| Fork's real guard resumes | added `codev_parent_thread_id`, found `codev_role` present | +| Pre-fork binary opens the fully applied file | opened and answered | + +Seven tests assert the evidence, including one that refuses evidence older than +`criterion-8b.mjs`, `crash-apply-child.mjs` or `t3-server.mjs`. + +The resume step runs the **production** `codevSchemaGuardStep` through +`apps/server/scripts/apply-codev-guard.ts`, not a copy of its statements — a script with its own +`ALTER`s would have proved the statements work and nothing about the guard, which is the same +mistake as #1. + +### What this uncovered, and it is larger than the finding + +**The criterion could not be expressed with the tools that existed.** `restart` is stop-then-start +and refuses when nothing is running — and after a kill, nothing is. `start` wipes the data dir +before starting. Neither can open a database the run did not just create, which is the whole of +criterion 8b. + +So the in-memory simulation was not laziness; it was the only form the harness could express. There +was no failing test, no error and no skip to say so. The criterion sat in the plan reading exactly +like one that passes, and it took writing the test to discover the test could not be written. + +Fixed by adding `start --keep-data` to `tools/t3-server/t3-server.mjs`. Written up in the review +under "Why `start --keep-data` had to exist", and filed as evidence on #199. + +## claude #3 — `forkSkipReason` says "ahead" for any non-matching head — ACCEPTED + +Correct. Behind and unrelated were both being reported as "ahead of contract commit", which is the +*tolerated* state for phases 2-4. A genuinely broken checkout would have hidden inside the expected +case for three phases — the exact outcome the ahead-vs-wrong distinction was introduced to prevent, +reintroduced one layer up in the test gate. + +**Fixed.** The relation is computed with `merge-base --is-ancestor` in both directions and the skip +reason names which of four cases it is: at, ahead (expected until phase 5), **BEHIND** (not the +expected state, wants looking at), or **UNRELATED** (no ancestry either way). + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification after the fixes + +- Fork `992b781f4314`, pushed. Fork typecheck green; contracts 291 passed; server 2769 passed. +- Codev repo: build green, **7283 passed, 55 skipped, 0 failed**, plus 180 in the v2 suite. +- 73 tests in the spec 250 suite. +- The one fork failure, `entrypoint.test.ts`'s symlink case, remains pre-existing and unrelated: + byte-identical to the base commit, imports only `node:fs` and `node:url`, fails on macOS's + `/var` → `/private/var` resolution. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter2-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter2-context.md new file mode 100644 index 000000000..d84d64dcd --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_2-iter2-context.md @@ -0,0 +1,110 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Hierarchy columns, guard and projector are correct and well covered, but two of phase 2's own acceptance criteria are tested by substitution rather than by the mechanism they name. +- opencode: COMMENT — Hierarchy fields, out-of-registry guard, and projection path match the phase; criterion 8b is simulated, not opened against the pre-fork binary. + +### Builder Response to Iteration 1 +# Spec 250, phase_2, iteration 1 — review responses + +**claude** REQUEST_CHANGES, **opencode** COMMENT. Three findings, all accepted, none disputed. +Both lanes named the two substitutions independently. + +--- + +## claude #1 / opencode — "a newly introduced upstream migration still runs" never invoked the migrator — ACCEPTED + +> `schemaGuard.test.ts:145` never invokes the migrator, so "a newly introduced upstream migration +> still runs after the guard" is unproven. + +Correct, and the citation is exact: `:155` was a raw +`ALTER TABLE projection_threads ADD COLUMN pretend_upstream_column TEXT`. + +That statement proves SQLite accepts another column. It proves nothing about whether the +**watermark** let the migrator execute one — which is the entire question migration 900 got wrong, +and therefore the entire reason the test exists. The substitution was adjacent enough to the real +mechanism to produce identical observable state: a column appeared either way. Only the path it +appeared by was ever in question, and that is exactly what the assertion had dropped. + +**Fixed.** `runMigrations({ toMigrationInclusive: 41 })` → `applyCodevSchemaGuard()` → +`runMigrations()`, asserting migration 42 executed *and* that its column exists — recorded-as-run +and actually-applied being two claims. That is upstream's own idiom from +`042_ProjectionThreadLinkedPullRequest.test.ts:16-17`, as the lane pointed out. + +Added alongside it: a test that the guard writes **no rows at all** to `effect_sql_migrations`, so +every id above 41 stays free for upstream. + +## claude #2 / opencode — criterion 8b was simulated, not exercised — ACCEPTED + +> No process kill, no file-backed DB, nothing opened against the pinned pre-fork server. Exercise +> it or record the substitution as a deviation. + +Correct. The criterion names four things — a kill, a partial application, a resulting database, and +the pre-fork server binary — and the test had one of them. It still passed. + +**Exercised, not recorded as a deviation.** `tools/t3-fork/criterion-8b.mjs` runs the real sequence +and writes `codev/research/250-criterion-8b-evidence.json`: + +| Step | Result | +|---|---| +| Pinned `t3@0.0.36` creates and migrates a real database | opened and answered | +| Codev columns present beforehand | none — otherwise the run proves nothing | +| Child SIGKILLed after the first `ALTER` | `SIGKILL`; `codev_role` alone on disk | +| **Pre-fork binary opens the half-applied file** | **opened and answered** | +| Fork's real guard resumes | added `codev_parent_thread_id`, found `codev_role` present | +| Pre-fork binary opens the fully applied file | opened and answered | + +Seven tests assert the evidence, including one that refuses evidence older than +`criterion-8b.mjs`, `crash-apply-child.mjs` or `t3-server.mjs`. + +The resume step runs the **production** `codevSchemaGuardStep` through +`apps/server/scripts/apply-codev-guard.ts`, not a copy of its statements — a script with its own +`ALTER`s would have proved the statements work and nothing about the guard, which is the same +mistake as #1. + +### What this uncovered, and it is larger than the finding + +**The criterion could not be expressed with the tools that existed.** `restart` is stop-then-start +and refuses when nothing is running — and after a kill, nothing is. `start` wipes the data dir +before starting. Neither can open a database the run did not just create, which is the whole of +criterion 8b. + +So the in-memory simulation was not laziness; it was the only form the harness could express. There +was no failing test, no error and no skip to say so. The criterion sat in the plan reading exactly +like one that passes, and it took writing the test to discover the test could not be written. + +Fixed by adding `start --keep-data` to `tools/t3-server/t3-server.mjs`. Written up in the review +under "Why `start --keep-data` had to exist", and filed as evidence on #199. + +## claude #3 — `forkSkipReason` says "ahead" for any non-matching head — ACCEPTED + +Correct. Behind and unrelated were both being reported as "ahead of contract commit", which is the +*tolerated* state for phases 2-4. A genuinely broken checkout would have hidden inside the expected +case for three phases — the exact outcome the ahead-vs-wrong distinction was introduced to prevent, +reintroduced one layer up in the test gate. + +**Fixed.** The relation is computed with `merge-base --is-ancestor` in both directions and the skip +reason names which of four cases it is: at, ahead (expected until phase 5), **BEHIND** (not the +expected state, wants looking at), or **UNRELATED** (no ancestry either way). + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification after the fixes + +- Fork `992b781f4314`, pushed. Fork typecheck green; contracts 291 passed; server 2769 passed. +- Codev repo: build green, **7283 passed, 55 skipped, 0 failed**, plus 180 in the v2 suite. +- 73 tests in the spec 250 suite. +- The one fork failure, `entrypoint.test.ts`'s symlink case, remains pre-existing and unrelated: + byte-identical to the base commit, imports only `node:fs` and `node:url`, fails on macOS's + `/var` → `/private/var` resolution. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_3-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_3-iter1-rebuttals.md new file mode 100644 index 000000000..e329ee443 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_3-iter1-rebuttals.md @@ -0,0 +1,93 @@ +# Spec 250, phase_3, iteration 1 — review responses + +Both lanes REQUEST_CHANGES. Four findings, all accepted, none disputed. Both lanes found the +blocking one independently. + +--- + +## Blocking — the engine deleted every reason discriminant — ACCEPTED + +> `OrchestrationEngine.ts:177-185` collapses `CodevHierarchyInvalidError` into a generic +> `OrchestrationCommandInvariantError` with a misleading detail; the six discriminants never reach +> a dispatcher, and the false message is persisted onto the command receipt at `:310` and replayed +> on redispatch. + +Correct on every point, including the two locations and that they had to be fixed together. + +The mapping passed `OrchestrationCommandInvariantError` through and rewrote everything else as +`"Failed to generate an event identifier."` — which for a hierarchy refusal is not merely lossy, +it is **false**. It then reached `:310`, where a rejected receipt records `error.message`, and that +receipt is replayed verbatim on any redispatch of the same `commandId`. So the wrong answer was not +a one-time bad log; it was the permanent answer to that command. + +**The whole phase 3 deliverable was being deleted one layer above where it was tested.** Six +discriminants exist so a caller can tell a retry ("no such parent") from a caller bug ("wrong +parent role"). None of them left the decider. + +**Fixed.** `isRefusal` passes both `OrchestrationCommandInvariantError` and +`CodevHierarchyInvalidError` through, at the mapping and at the receipt branch. + +## Why it went unnoticed, which is the more useful half — ACCEPTED + +> No test dispatches a refusal through the engine; decider-only tests bypass the wrapper. + +Exactly right, and this is the same shape as phase 2's `MigrationsLive` finding: **testing the +layer below the one production uses.** All fifteen decider tests were green while the boundary +above them destroyed their subject. A discriminant that does not survive the wrapper does not +exist, and no amount of testing beneath the wrapper can say so. + +**Fixed.** `OrchestrationEngine.codevHierarchy.test.ts` dispatches through the real engine and +asserts the reason arrives intact, that distinct reasons stay distinct at the boundary, and that +the rejected receipt records the real cause. It also asserts the *absence* of the false string, so +a regression is loud rather than merely different. + +**Verified to discriminate rather than assumed.** With the mapping reverted to the old form, 3 of +its 4 tests fail; restored, all 4 pass. A regression test that does not fail on the regression is +the thing this project keeps getting caught by, so it was checked rather than trusted. + +## Non-blocking — a test that asserted its own literals — ACCEPTED + +> `decider.codevHierarchy.test.ts:277-291` asserts a locally-built Set's size and cannot detect the +> discriminant collapse it claims to guard. + +Correct, and it is worse than useless: it *claimed* to guard the collapse. It built a `Set` of six +string literals and asserted `size === 6`, which proves six distinct strings are six distinct +strings. Every refusal could have collapsed onto one discriminant and it would have passed. + +**Fixed.** It now dispatches all six cases and collects the reasons the decider actually returned, +asserting both the exact sequence and that the set of returned reasons has six members. + +## Non-blocking — a test that asserted its own input fixture — ACCEPTED + +> `decider.codevHierarchy.test.ts:354-374` asserts against its own input fixture, not against any +> decider output. + +Correct. It archived a parent and then asserted on `model.threads` — the object it had just +constructed, which the decider never mutates. It would have passed whatever the decider emitted. + +**Fixed.** It now asserts the decider's output: exactly one event, `thread.archived`, on the parent's +aggregate, and no event mentioning the child. That is the real property — orphaning happens by +*omission*, not by a cascade someone has to remember not to write. Whether the child stays readable +afterwards is a persistence question, and that is asserted for real in `codev/threadHierarchy.test.ts`. + +## Non-blocking — `commandInvariants.test.ts` had no Codev cases — ACCEPTED + +The plan listed it as extended and it was not. + +**Fixed.** Five cases, including the two ordering decisions stated as tests: `parent-is-self` +reported ahead of `parent-not-found` when both hold (the fixture has both properties, so it can +only pass if the order is right), and `parent-in-other-project` rather than `parent-not-found`. +Plus: an omitted `parentThreadId` key and an explicit `null` must reach the same refusal, because a +rule that fires on only one spelling is a rule with a hole in it. + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification after the fixes + +- Fork `40fb82ce92a8`, pushed. Fork typecheck green; server **2797 passed, 8 skipped**. +- 4 engine tests, 15 decider tests, 8 `commandInvariants` tests, 9 persistence tests. +- The one fork failure, `entrypoint.test.ts`'s symlink case, remains pre-existing and unrelated. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter1-rebuttals.md new file mode 100644 index 000000000..5b470c1ba --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter1-rebuttals.md @@ -0,0 +1,126 @@ +# Spec 250, phase_4, iteration 1 — review responses + +Both lanes REQUEST_CHANGES. Eight findings, all accepted, none disputed. Both lanes independently +found the blocking one, and it is the same defect as phase 3's in the same function. + +--- + +## BLOCKING — the engine deleted gate refusals — ACCEPTED + +> `isRefusal` does not include `CodevGateWriteError`, so criterion 10 is false at the wire. + +Correct, and this is the sharpest thing in the round. Phase 3 fixed `isRefusal` once, for +`CodevHierarchyInvalidError`. Phase 4 added a **third** refusal type and did not extend it. So every +gate refusal — including the stale write that *is* criterion 10 — was rewritten as +`"Failed to generate an event identifier"` and persisted onto the rejected receipt. + +**All eleven decider tests stayed green throughout**, because they call the decider directly. The +same lesson as phase 3, and I still walked into it: *adding a refusal type without adding it to +`isRefusal` is now the same mistake three times.* + +**Fixed.** `isRefusal` covers all three; `CodevGateWriteError` added to `OrchestrationDispatchError`. +An engine-level test asserts the stale write arrives carrying `CODEV_GATE_REVISION_STALE`, and +**verified to discriminate**: with `isRefusal` reverted it fails, restored it passes. + +## "Could not tell" shared a spelling with "no" — ACCEPTED + +> `ws.ts` returns `CODEV_GATE_THREAD_NOT_FOUND` for a committed-but-unconfirmed write. + +Correct, and the lane's second pass sharpened it into the more serious version: **this is the +routine retry path, not a rare defensive one.** An idempotent replay of the same `commandId` commits +nothing, returns an empty event array, and lands there *every time*. A normal retry was being +reported to the caller as a nonexistent thread. + +**Fixed.** `CODEV_GATE_WRITE_UNCONFIRMED`, with a message that says explicitly: do not assume it +applied, do not assume it did not, re-read the thread. + +## Every unexpected cause was relabelled as a missing thread — ACCEPTED + +**Fixed.** `CODEV_GATE_WRITE_FAILED` for database and decode failures. Relabelling those as a +missing thread sends someone to look for a thread that is there, and hides a broken database behind +what reads like ordinary caller error. + +## `CODEV_GATE_SCOPE_REQUIRED` was declared and never constructed — ACCEPTED, dropped + +Both lanes: emit it or drop it. **Dropped.** The real refusal is `EnvironmentAuthorizationError` +carrying `requiredScope: "codev:gate-write"`, raised by the transport before the handler runs — and +that is already distinguishable from a 401 and from any other scope failure. A second refusal path +for a case the transport already blocks would be unreachable code that only a test bypassing +production could reach. + +### And the same defect one place further, which the fix surfaced + +`CODEV_GATE_THREAD_NOT_FOUND` was *also* declared and never constructed: a missing thread went +through `requireThread`, which raises the generic invariant error. The gate RPC declares its error +as `CodevGateWriteError`, so the declared error type was a lie for the commonest failure. + +Found by **tightening a test**, not by reading: replacing `expect(_tag).toBe("Failure")` with an +assertion on the reason failed immediately. Now the decider raises the declared error. + +## Two of my own tests asserted nothing — ACCEPTED + +> Assertions guarded behind `if (events[0]?.type === ...)` with nothing outside; the +> thread-not-found test asserts only `_tag === "Failure"`. + +Both correct. The guarded form passes vacuously when the type is wrong — the test reports success by +asserting nothing at all. This is the **third** time this round of reviews has caught me writing a +test that cannot fail. + +**Fixed.** `gateSetPayload` / `gateClearedPayload` assert the event type and return the payload, so +a wrong type fails there. The thread-not-found test now asserts the reason. + +## THE FINDING THAT MATTERS MOST — no projector coverage — ACCEPTED + +> No test asserts the projector or pipeline applies either gate event. A projector that dropped +> `gateRevision` would pass all 11 decider tests while every write after the first re-allocated +> revision 1. + +This is the best finding of the round and the lane is exactly right about why: every decider test +**hand-builds its read model**, so they cannot see a projector that forgets the mark. And a +forgotten mark is not a cosmetic bug — it is the precise failure the revision mechanism exists to +prevent, invisible to the suite that was meant to protect it. + +**Fixed.** Six projector tests, including one that plays set → clear → set and asserts the mark is +3 rather than 1. **Verified to discriminate**: with `gateRevision` dropped from the projector, two +of the six fail. + +## The OAuth token allowlist exclusion was unasserted — ACCEPTED + +The scope is excluded from three places and only two were tested. `AuthStandardClientScopes` governs +what a client is *issued*; the `auth/http.ts` allowlist governs what a client may *ask for*. Excluded +from the first and present in the second is not excluded at all. + +**Fixed.** Asserted against the source, because the list is an inline literal with no exported value +— which is itself worth noting: an allowlist nobody can read from a test is an allowlist nobody can +check. + +## The single credential was never named — ACCEPTED + +`apps/server/src/codev/gateCredential.ts` names both halves the plan asked for: the issuance API +(`EnvironmentAuth.issueSession`, upstream's own, not a new mechanism) and the on-disk path +(`/codev/gate-writer.token`, mode `0600`). + +It holds `orchestration:read` + `codev:gate-write` and deliberately **not** `orchestration:operate`: +`codev-agent` publishes gate state, it does not create threads or drive turns. Granting operate +would make this credential a superset of an ordinary client's and remove the point of separating +them. The scope set is asserted as a *set*, not a containment check, because a containment check +passes while the credential quietly grows. + +The token is written to a temp file and renamed, so a reader never sees a half-written token — a +truncated bearer token fails authentication in a way that looks like revocation. + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification after the fixes + +- Fork `3d0e76776cd9`, pushed. Fork typecheck green; contracts **304 passed**; server + **2832 passed, 8 skipped**, 1 pre-existing. +- Three regression tests verified to fail when their mechanism is removed: the engine's `isRefusal`, + the projector's mark, and the wire-level decoding default. +- `server.test.ts > routes websocket rpc server.upsertKeybinding` failed once under the full + parallel run and passes in isolation and on re-run. Recorded as flaky, not fixed; likely the same + shared-resource class as issue #263. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter2-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter2-rebuttals.md new file mode 100644 index 000000000..10db09d7a --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter2-rebuttals.md @@ -0,0 +1,77 @@ +# Spec 250, phase_4, iteration 2 — review responses + +claude APPROVE, opencode REQUEST_CHANGES. They agree on the substance: the same single gap, which +opencode treated as blocking and claude as carry-forward. **Treated as blocking**, because the plan +says the credential is *"provisioned out of band **at server start**"* — that makes it phase 4's +work, not the consumer's phase. + +--- + +## The credential had no production caller — ACCEPTED, and it is costume one again + +> `writeCodevGateWriterToken` has no production caller — the credential is named and tested but +> nothing issues it at server start. + +Correct, and it is worth naming precisely what happened. `gateCredential.ts` named the scopes, named +the on-disk path, and tested the write. Nothing in the server ever called any of it. + +That is **costume one from this very phase's review** — "a thing tested in isolation that production +never builds" — produced in the same phase that added the hot-tier lesson about it, one commit after +writing the four-costume table. The module's own tests were all green and all meaningless for the +question that mattered. + +**Fixed.** `provisionCodevGateWriter` runs as a named startup phase, +`"codev.gate-writer.provision"`, handed the server's own `baseDir` and +`EnvironmentAuth.issueSession`. + +Two decisions inside it, both stated rather than left implicit: + +- **Non-fatal.** A server that cannot write the token is still a working server for every other + client, and failing the whole boot over `codev-agent`'s credential would take the UI down with it. + It logs `CODEV_GATE_WRITER_PROVISION_FAILED` so the failure is not met later as an unexplained + authorization error. +- **Idempotent by rotation, not by lookup.** A fresh session each start, file overwritten. Reusing + an existing token would mean reading a bearer credential back off disk to decide whether to keep + it, and a server that reads tokens is a larger target than one that only writes them. A stale + session expires on its own TTL. + +**The test asserts against the production source**, because "production calls this" is a fact about +the call site and not about the module. Verified to discriminate: removing the startup phase fails +it. + +## The map row was asserted, the enforcement was not — ACCEPTED + +> No test asserts the wire-level refusal for a caller holding only `orchestration:operate`; the map +> row is asserted but not the enforcement. + +Correct, and the same shape one layer down: **a row nothing reads documents an intention.** The row +is only load-bearing if `requiredScopeForRpcMethod` is on the path every RPC takes. + +**Fixed.** Asserts `ws.ts` routes through it on **both** wrappers — a method registered as a stream +would otherwise slip past the effect-only one — and that the gate handler is registered through the +instrumented wrapper rather than bare. Plus: an unmapped method **throws** rather than defaulting to +something permissive, which is the failure direction that matters for a new RPC. + +## Source-string assertions are brittle to reformatting — ACKNOWLEDGED, not changed + +Fair, and already mitigated the way the lane notes: every one carries an existence guard and a +positive control, so a moved or renamed target fails loudly rather than passing vacuously. + +Not changed because the alternative is worse. Three of these assert **absences** — a command not in +a union, a scope not in an allowlist, a call site that must exist — and an absence has no runtime +value to inspect. A brittle test that fails when the code moves is a maintenance cost; no test at +all is how all four costumes shipped. + +--- + +## Not changed + +Only the brittleness note above, with reasons. Neither other finding was a false positive. + +## Verification after the fixes + +- Fork `0254c84e1241`, pushed. Fork typecheck green; server **2839 passed, 8 skipped**, 1 + pre-existing. +- Four regression tests in phase 4 verified by removing their mechanism and watching the specific + test go red: `isRefusal`, the projector's mark, the wire decoding default, and now the startup + provisioning. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-context.md new file mode 100644 index 000000000..2181d5dea --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-context.md @@ -0,0 +1,224 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Gate mechanism and revision semantics are correct; three failure-reason defects in the contract and ws handler should be fixed before phase 5 vendors them. +- opencode: REQUEST_CHANGES — Engine still rewrites CodevGateWriteError, so criterion 10 is false at the wire; gate-write credential is never issued. + +### Builder Response to Iteration 1 +# Spec 250, phase_4, iteration 1 — review responses + +Both lanes REQUEST_CHANGES. Eight findings, all accepted, none disputed. Both lanes independently +found the blocking one, and it is the same defect as phase 3's in the same function. + +--- + +## BLOCKING — the engine deleted gate refusals — ACCEPTED + +> `isRefusal` does not include `CodevGateWriteError`, so criterion 10 is false at the wire. + +Correct, and this is the sharpest thing in the round. Phase 3 fixed `isRefusal` once, for +`CodevHierarchyInvalidError`. Phase 4 added a **third** refusal type and did not extend it. So every +gate refusal — including the stale write that *is* criterion 10 — was rewritten as +`"Failed to generate an event identifier"` and persisted onto the rejected receipt. + +**All eleven decider tests stayed green throughout**, because they call the decider directly. The +same lesson as phase 3, and I still walked into it: *adding a refusal type without adding it to +`isRefusal` is now the same mistake three times.* + +**Fixed.** `isRefusal` covers all three; `CodevGateWriteError` added to `OrchestrationDispatchError`. +An engine-level test asserts the stale write arrives carrying `CODEV_GATE_REVISION_STALE`, and +**verified to discriminate**: with `isRefusal` reverted it fails, restored it passes. + +## "Could not tell" shared a spelling with "no" — ACCEPTED + +> `ws.ts` returns `CODEV_GATE_THREAD_NOT_FOUND` for a committed-but-unconfirmed write. + +Correct, and the lane's second pass sharpened it into the more serious version: **this is the +routine retry path, not a rare defensive one.** An idempotent replay of the same `commandId` commits +nothing, returns an empty event array, and lands there *every time*. A normal retry was being +reported to the caller as a nonexistent thread. + +**Fixed.** `CODEV_GATE_WRITE_UNCONFIRMED`, with a message that says explicitly: do not assume it +applied, do not assume it did not, re-read the thread. + +## Every unexpected cause was relabelled as a missing thread — ACCEPTED + +**Fixed.** `CODEV_GATE_WRITE_FAILED` for database and decode failures. Relabelling those as a +missing thread sends someone to look for a thread that is there, and hides a broken database behind +what reads like ordinary caller error. + +## `CODEV_GATE_SCOPE_REQUIRED` was declared and never constructed — ACCEPTED, dropped + +Both lanes: emit it or drop it. **Dropped.** The real refusal is `EnvironmentAuthorizationError` +carrying `requiredScope: "codev:gate-write"`, raised by the transport before the handler runs — and +that is already distinguishable from a 401 and from any other scope failure. A second refusal path +for a case the transport already blocks would be unreachable code that only a test bypassing +production could reach. + +### And the same defect one place further, which the fix surfaced + +`CODEV_GATE_THREAD_NOT_FOUND` was *also* declared and never constructed: a missing thread went +through `requireThread`, which raises the generic invariant error. The gate RPC declares its error +as `CodevGateWriteError`, so the declared error type was a lie for the commonest failure. + +Found by **tightening a test**, not by reading: replacing `expect(_tag).toBe("Failure")` with an +assertion on the reason failed immediately. Now the decider raises the declared error. + +## Two of my own tests asserted nothing — ACCEPTED + +> Assertions guarded behind `if (events[0]?.type === ...)` with nothing outside; the +> thread-not-found test asserts only `_tag === "Failure"`. + +Both correct. The guarded form passes vacuously when the type is wrong — the test reports success by +asserting nothing at all. This is the **third** time this round of reviews has caught me writing a +test that cannot fail. + +**Fixed.** `gateSetPayload` / `gateClearedPayload` assert the event type and return the payload, so +a wrong type fails there. The thread-not-found test now asserts the reason. + +## THE FINDING THAT MATTERS MOST — no projector coverage — ACCEPTED + +> No test asserts the projector or pipeline applies either gate event. A projector that dropped +> `gateRevision` would pass all 11 decider tests while every write after the first re-allocated +> revision 1. + +This is the best finding of the round and the lane is exactly right about why: every decider test +**hand-builds its read model**, so they cannot see a projector that forgets the mark. And a +forgotten mark is not a cosmetic bug — it is the precise failure the revision mechanism exists to +prevent, invisible to the suite that was meant to protect it. + +**Fixed.** Six projector tests, including one that plays set → clear → set and asserts the mark is +3 rather than 1. **Verified to discriminate**: with `gateRevision` dropped from the projector, two +of the six fail. + +## The OAuth token allowlist exclusion was unasserted — ACCEPTED + +The scope is excluded from three places and only two were tested. `AuthStandardClientScopes` governs +what a client is *issued*; the `auth/http.ts` allowlist governs what a client may *ask for*. Excluded +from the first and present in the second is not excluded at all. + +**Fixed.** Asserted against the source, because the list is an inline literal with no exported value +— which is itself worth noting: an allowlist nobody can read from a test is an allowlist nobody can +check. + +## The single credential was never named — ACCEPTED + +`apps/server/src/codev/gateCredential.ts` names both halves the plan asked for: the issuance API +(`EnvironmentAuth.issueSession`, upstream's own, not a new mechanism) and the on-disk path +(`/codev/gate-writer.token`, mode `0600`). + +It holds `orchestration:read` + `codev:gate-write` and deliberately **not** `orchestration:operate`: +`codev-agent` publishes gate state, it does not create threads or drive turns. Granting operate +would make this credential a superset of an ordinary client's and remove the point of separating +them. The scope set is asserted as a *set*, not a containment check, because a containment check +passes while the credential quietly grows. + +The token is written to a temp file and renamed, so a reader never sees a half-written token — a +truncated bearer token fails authentication in a way that looks like revocation. + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification after the fixes + +- Fork `3d0e76776cd9`, pushed. Fork typecheck green; contracts **304 passed**; server + **2832 passed, 8 skipped**, 1 pre-existing. +- Three regression tests verified to fail when their mechanism is removed: the engine's `isRefusal`, + the projector's mark, and the wire-level decoding default. +- `server.test.ts > routes websocket rpc server.upsertKeybinding` failed once under the full + parallel run and passes in isolation and on re-run. Recorded as flaky, not fixed; likely the same + shared-resource class as issue #263. + + +### Iteration 2 Reviews +- claude: APPROVE — Phase 4 delivers the gate block, server-allocated revision, isolated scope and RPC method; all 8 iteration-1 findings fixed with discriminating tests verified. +- opencode: REQUEST_CHANGES — Gate revision and scope machinery is in; the single codev:gate-write credential is still never provisioned at server start. + +### Builder Response to Iteration 2 +# Spec 250, phase_4, iteration 2 — review responses + +claude APPROVE, opencode REQUEST_CHANGES. They agree on the substance: the same single gap, which +opencode treated as blocking and claude as carry-forward. **Treated as blocking**, because the plan +says the credential is *"provisioned out of band **at server start**"* — that makes it phase 4's +work, not the consumer's phase. + +--- + +## The credential had no production caller — ACCEPTED, and it is costume one again + +> `writeCodevGateWriterToken` has no production caller — the credential is named and tested but +> nothing issues it at server start. + +Correct, and it is worth naming precisely what happened. `gateCredential.ts` named the scopes, named +the on-disk path, and tested the write. Nothing in the server ever called any of it. + +That is **costume one from this very phase's review** — "a thing tested in isolation that production +never builds" — produced in the same phase that added the hot-tier lesson about it, one commit after +writing the four-costume table. The module's own tests were all green and all meaningless for the +question that mattered. + +**Fixed.** `provisionCodevGateWriter` runs as a named startup phase, +`"codev.gate-writer.provision"`, handed the server's own `baseDir` and +`EnvironmentAuth.issueSession`. + +Two decisions inside it, both stated rather than left implicit: + +- **Non-fatal.** A server that cannot write the token is still a working server for every other + client, and failing the whole boot over `codev-agent`'s credential would take the UI down with it. + It logs `CODEV_GATE_WRITER_PROVISION_FAILED` so the failure is not met later as an unexplained + authorization error. +- **Idempotent by rotation, not by lookup.** A fresh session each start, file overwritten. Reusing + an existing token would mean reading a bearer credential back off disk to decide whether to keep + it, and a server that reads tokens is a larger target than one that only writes them. A stale + session expires on its own TTL. + +**The test asserts against the production source**, because "production calls this" is a fact about +the call site and not about the module. Verified to discriminate: removing the startup phase fails +it. + +## The map row was asserted, the enforcement was not — ACCEPTED + +> No test asserts the wire-level refusal for a caller holding only `orchestration:operate`; the map +> row is asserted but not the enforcement. + +Correct, and the same shape one layer down: **a row nothing reads documents an intention.** The row +is only load-bearing if `requiredScopeForRpcMethod` is on the path every RPC takes. + +**Fixed.** Asserts `ws.ts` routes through it on **both** wrappers — a method registered as a stream +would otherwise slip past the effect-only one — and that the gate handler is registered through the +instrumented wrapper rather than bare. Plus: an unmapped method **throws** rather than defaulting to +something permissive, which is the failure direction that matters for a new RPC. + +## Source-string assertions are brittle to reformatting — ACKNOWLEDGED, not changed + +Fair, and already mitigated the way the lane notes: every one carries an existence guard and a +positive control, so a moved or renamed target fails loudly rather than passing vacuously. + +Not changed because the alternative is worse. Three of these assert **absences** — a command not in +a union, a scope not in an allowlist, a call site that must exist — and an absence has no runtime +value to inspect. A brittle test that fails when the code moves is a maintenance cost; no test at +all is how all four costumes shipped. + +--- + +## Not changed + +Only the brittleness note above, with reasons. Neither other finding was a false positive. + +## Verification after the fixes + +- Fork `0254c84e1241`, pushed. Fork typecheck green; server **2839 passed, 8 skipped**, 1 + pre-existing. +- Four regression tests in phase 4 verified by removing their mechanism and watching the specific + test go red: `isRefusal`, the projector's mark, the wire decoding default, and now the startup + provisioning. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-rebuttals.md new file mode 100644 index 000000000..23c040271 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_4-iter3-rebuttals.md @@ -0,0 +1,82 @@ +# Spec 250, phase_4, iteration 3 — review responses + +**Both lanes APPROVE.** opencode: KEY_ISSUES none. claude: APPROVE with three non-blocking +findings. All three fixed here rather than filed, because the ruling on this project is that +structural fixes are in-phase — a follow-up issue is a promise to hit it again. + +Fork `51b55d4899e4`, pushed. + +--- + +## `OrchestrationRefusal` kept a second copy of the refusal list — ACCEPTED + +> `OrchestrationRefusal` hand-lists the same three tags `dispatchErrorKind` now owns. Classifying +> a fourth refusal in the switch without touching the Extract narrows to the wrong type. + +Correct, and it is the same shape as the bug the switch was added to kill, one line below it. +Runtime would have stayed right while the type quietly lied — which is worse than the original, +because the compile-time mechanism I had just installed would have been sitting there looking +like it covered this. + +**Fixed by making the classification data instead of control flow.** `DISPATCH_ERROR_KIND` is a +table under `as const satisfies { readonly [K in OrchestrationDispatchError["_tag"]]: ... }`: +a missing member is a missing key, an extra one is an excess property. `OrchestrationRefusal` is +derived from the table's literal values, so there is nothing left to forget to update. + +Verified both directions: + +- delete the `CodevGateWriteError` row → `TS2741: Property 'CodevGateWriteError' is missing`, + naming the tag; +- flip it to `"internal"` → 2 engine tests go red, including the one asserting the stale write + reaches the dispatcher still carrying `CodevGateWriteError`. + +The `?? "internal"` on the lookup is kept and is load-bearing at runtime even though the index +signature is total: `isRefusal` reaches it with an `unknown` cause it has only shape-checked. The +existing test feeding it `_tag: "SomethingFromTheFuture"` covers that path. + +## The doc comment documented a reason that cannot arrive — ACCEPTED + +> The comment above `CodevGateWriteErrorReason` still says "Three causes" and documents +> `CODEV_GATE_SCOPE_REQUIRED`, dropped from the union. + +Correct, and the lane names the cost precisely: a phase 6/8 consumer reads that comment and +writes a branch for a reason no server will ever send. A stale comment on a wire type is not +cosmetic — it is a contract that disagrees with itself, and the reader has no way to know which +half is current. + +**Fixed.** The comment now says four, and says why the scope case is *not* one of them: it is +refused by the transport as `EnvironmentAuthorizationError` with +`requiredScope: "codev:gate-write"`, before the handler runs. Written as an instruction not to +add it back, since "there is no reason for the scope case" invites exactly that. + +## The source assertion was brittle to reformatting — ACCEPTED + +Raised in iteration 2 as well; I acknowledged it and did not change it. The lane's iteration-3 +version is narrower and it is right: this particular one asserted **exact indentation**, and the +fact under test is only "gateWrite is the first argument to `observeRpcEffect`". + +**Fixed** with a whitespace-tolerant regex. Verified both directions, because a laxer assertion +has to be shown still able to fail: + +- register the gate handler bare → fails; +- collapse the call to one line → passes (10 passed). + +The three assertions that check **absences** are unchanged, for the reason given in iteration 2: +an absence has no runtime value to inspect, and each carries a positive control. + +--- + +## Not changed + +Nothing. No finding in this round was a false positive. + +## Verification + +- Fork `51b55d4899e4`, pushed. Typecheck green (contracts + server). +- Server suite **2839 passed, 8 skipped, 1 failed** — `entrypoint.test.ts > matches through a + symlinked entrypoint`, pre-existing, unmodified, byte-identical to the base commit. Same counts + as iteration 2. +- `250-criterion-8b-evidence.json` regenerated at the new fork HEAD; `passed: true`, and the + harness re-verified upstream clean at `082e6ea52186`. +- Four discrimination checks run for the three fixes (missing key, flipped classification, bare + registration, reformat tolerance). diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter1-rebuttals.md new file mode 100644 index 000000000..75029586e --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter1-rebuttals.md @@ -0,0 +1,43 @@ +# Phase 5, iteration 1 — response to the 2-way review + +**claude: REQUEST_CHANGES. opencode: COMMENT.** Both lanes raise the same single issue and both +are correct. Nothing is rebutted; the fix is in-phase. + +## The finding + +`packages/types/src/t3/generated/schema.ts:2` read + +``` +// Source: https://github.com/pingdotgg/t3code @ 51b55d4899e4d900dfa0a7995f6f9200c53d10c0 +``` + +That commit exists only in `pseudoseed/t3code`. `ATTRIBUTION.md` and `types.d.ts` were corrected +to name the fork and the base; `schema.ts` was not, and it is the module that actually ships. +`ATTRIBUTION.md`'s own generated text states the rule the line breaks. + +**Accepted, and fixed one level up.** The three headers were three separate emissions of one +claim, and the third was a hand-written string in a different part of `generate.mjs`. Correcting +it in place would have left the same shape that produced the miss. There is now a single +`PROVENANCE` constant and all emitters read it, so the next artifact to acquire a header cannot +disagree with the others. + +## The test that let it through, and what replaced it + +The claude lane's second key issue is the sharper of the two: the attribution test named +`ATTRIBUTION.md` and `types.d.ts`, so the one artifact not on the list was the one that drifted. +That is the enumeration failure this project has hit before, and extending the list to three files +would repeat it. + +The test is now **derived from the directory**: every generated artifact naming `pin.repo` must +also name `pin.forkRepo` and `pin.upstreamBase`. It reads `readdirSync(generated)` rather than a +literal list, asserts it found artifacts at all so it cannot pass vacuously, and covers a fourth +artifact before that artifact exists. + +Verified by reverting: restoring the hand-written `schema.ts` header and regenerating fails the +new test and nothing else. The suggested fix — extend the enumeration to three files — would also +have caught this instance; it would not have caught the next one. + +## Not changed + +Neither lane asked for anything else. Both confirmed the deliverables, the union verdict with its +negative control, and the three in-phase structural fixes. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-context.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-context.md new file mode 100644 index 000000000..acde3373f --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-context.md @@ -0,0 +1,57 @@ +### Iteration 1 Reviews +- claude: REQUEST_CHANGES — Phase 5 is complete and well tested, but the shipped schema.ts header attributes a fork commit to the upstream repo and no test covers that artifact. +- opencode: COMMENT — Phase 5 deliverables are met; schema.ts still points pin.commit at pingdotgg/t3code. + +### Builder Response to Iteration 1 +# Phase 5, iteration 1 — response to the 2-way review + +**claude: REQUEST_CHANGES. opencode: COMMENT.** Both lanes raise the same single issue and both +are correct. Nothing is rebutted; the fix is in-phase. + +## The finding + +`packages/types/src/t3/generated/schema.ts:2` read + +``` +// Source: https://github.com/pingdotgg/t3code @ 51b55d4899e4d900dfa0a7995f6f9200c53d10c0 +``` + +That commit exists only in `pseudoseed/t3code`. `ATTRIBUTION.md` and `types.d.ts` were corrected +to name the fork and the base; `schema.ts` was not, and it is the module that actually ships. +`ATTRIBUTION.md`'s own generated text states the rule the line breaks. + +**Accepted, and fixed one level up.** The three headers were three separate emissions of one +claim, and the third was a hand-written string in a different part of `generate.mjs`. Correcting +it in place would have left the same shape that produced the miss. There is now a single +`PROVENANCE` constant and all emitters read it, so the next artifact to acquire a header cannot +disagree with the others. + +## The test that let it through, and what replaced it + +The claude lane's second key issue is the sharper of the two: the attribution test named +`ATTRIBUTION.md` and `types.d.ts`, so the one artifact not on the list was the one that drifted. +That is the enumeration failure this project has hit before, and extending the list to three files +would repeat it. + +The test is now **derived from the directory**: every generated artifact naming `pin.repo` must +also name `pin.forkRepo` and `pin.upstreamBase`. It reads `readdirSync(generated)` rather than a +literal list, asserts it found artifacts at all so it cannot pass vacuously, and covers a fourth +artifact before that artifact exists. + +Verified by reverting: restoring the hand-written `schema.ts` header and regenerating fails the +new test and nothing else. The suggested fix — extend the enumeration to three files — would also +have caught this instance; it would not have caught the next one. + +## Not changed + +Neither lane asked for anything else. Both confirmed the deliverables, the union verdict with its +negative control, and the three in-phase structural fixes. + + +### IMPORTANT: Stateful Review Context +This is NOT the first review iteration. Previous reviewers raised concerns and the builder has responded. +Before re-raising a previous concern: +1. Check if the builder has already addressed it in code +2. If the builder disputes a concern with evidence, verify the claim against actual project files before insisting +3. Do not re-raise concerns that have been explained as false positives with valid justification +4. Check package.json and config files for version numbers before flagging missing configuration diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-rebuttals.md new file mode 100644 index 000000000..ad53bb2bf --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_5-iter2-rebuttals.md @@ -0,0 +1,28 @@ +# Phase 5, iteration 2 — response to the 2-way review + +**Both lanes APPROVE**, claude at HIGH confidence after a second pass, opencode at HIGH. + +## The one non-blocking note, accepted + +claude: the directory-derived attribution test filtered `readdirSync(generated)` with +`!f.endsWith('.json')` — an enumeration, in the test written to remove one. Harmless today because +no JSON artifact carries a provenance line, and exempt for a reason nobody chose the moment one +does. + +Fixed. The filter is gone. Selecting on the **claim** — does this file name the upstream repository +— is the whole test, so nothing needs excluding in advance. The floor moved from `> 2` to `> 4` +since it now reads all eight artifacts. + +## Confirmed by the review, recorded here + +- `FORK.md`'s phase log holds 12 fork commits ending at `51b55d4899e4`, which is `pin.commit` and + is exactly the 12 files in `tools/t3-fork/patches/`. The export is a faithful + `upstreamBase..forkHEAD`, not a stale one. +- `FORK.md` states that phase 5 added no fork row because it changed nothing in the fork — an + absence that would otherwise read as an omission. +- `classify-churn.mjs` takes the schema module from `spec.source` with the same map as + `generate.mjs`, so `codev.gateWrite` is not reported as `` at every commit. + +## Not changed + +Neither lane raised a blocking issue. No deliverable from iteration 1 was disturbed. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter1-rebuttals.md new file mode 100644 index 000000000..809405903 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter1-rebuttals.md @@ -0,0 +1,55 @@ +# Phase 6, iteration 1 — response to the 2-way review + +**claude: APPROVE (HIGH). opencode: REQUEST_CHANGES (HIGH).** Four findings between them, all +accepted, all fixed in phase. Nothing rebutted. + +Per the standing order, the stricter lane is binding: opencode's finding is a real leak and it +decides the iteration. + +## opencode — the gate watch is not torn down on reconnect + +`gateWatches.set(key, ...)` dropped the previous closer on the floor, and the gate socket's close +handler was empty. + +**Correct, and the teardown I did write does not cover it.** `closeThreadBackend` stops the watch, +but a reconnect never goes through `closeThreadBackend`: `ensureThreadBackendReady` re-initialises a +workspace whose engine was evicted, which is exactly what a t3code restart causes. So Tower — which +runs for days — leaked a live `fs.watch` and a WebSocket per reconnect. + +Two halves, because there are two ways to leak one: + +1. The block now stops any existing watch **before** installing a new one. +2. The gate socket gets a close handler that evicts its own entry, guarded on the entry still being + the one it belongs to so a handler firing late cannot evict the watch that replaced it. Nothing + else would do it: this socket carries no engine, so the engine's close handler never sees it. + +**And it now has a test**, which the first fix did not. Reverting either half fails a named +assertion; verified both directions. A fix with no test is one line from regressing silently, and I +had written one. + +## claude — three non-blocking + +**`spawn.ts` claims "THREE ANSWERS" for a two-member union and lists `unowned` twice.** True. The +union has two members and `unowned` carries its reason in `detail`; the comment is rewritten to say +that, and to name the three ways `unowned` happens rather than pretending they are separate cases. + +**The serialized publish queue has no direct test.** Also true. The integration walk catches a +dropped cycle indirectly — the watcher fires on the same write a caller reacts to — but indirectly +is not deliberately. There is now a test that blocks the writer mid-cycle, changes the gate while +the first cycle is in flight, queues a second request behind it, and asserts BOTH gates reached the +server in order. Reverting the serialization fails it. + +It asserts on the writes rather than on which promise carries which result: `watchAgentState` +queues a cycle of its own when it subscribes, so which cycle publishes what is an implementation +detail. What the serialization must guarantee is that the gate which opened during the in-flight +write is not the one that goes missing. + +**The evidence freshness guard does not watch the client's read path.** Right, and it is the half +that matters most: the whole claim is that a *client* can read the discriminant, and `envelope.ts` +is where `RpcFailureError` decides what `error` and `tag` mean. `envelope.ts` and `client.ts` are +now in the guard alongside the live script and the harness. + +## Not changed + +Neither lane disputed the fork-side fix, the `start-fork` verb, or the gate publisher's design. +claude verified patch 0013 independently against the fork. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter2-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter2-rebuttals.md new file mode 100644 index 000000000..0e3114f6d --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_6-iter2-rebuttals.md @@ -0,0 +1,38 @@ +# Phase 6, iteration 2 — response to the 2-way review + +**Both lanes APPROVE at HIGH confidence.** One non-blocking note, accepted and fixed. + +## claude — the wire-evidence guard can flake on a fresh clone + +The mtime comparison assumed the filesystem records when a file was written. On a fresh clone it +records when git chose to write it, and git chooses an order — `codev/research/` can land before +`packages/`, making the evidence look older than a source it is perfectly current with. A guard +whose job is to be trusted must not fail for a reason unrelated to its subject. + +claude suggested commit time. **Tried, and it breaks differently**, which is worth recording because +the failure is not obvious: a file written, run, and THEN committed always has a commit time later +than the run it produced — and that is the ordinary way this script is edited. The first attempt +went red on correct, current evidence. + +So the evidence records **content hashes** of the sources it ran against, and the test recomputes +them. Neither side is a timestamp, so neither checkout order nor commit order can move it. It +answers what the guard means — is this evidence about the code that is here now — and it is the +mechanism `generated/source-hash.json` already uses for the contract. + +`packages/t3-client/src/envelope.ts` is in the hashed set, which was the other half of claude's +iteration-1 point: the claim is that a *client* can read the discriminant, and that file is where +`RpcFailureError` decides what `error` and `tag` mean. + +Verified by appending a line to `envelope.ts` and confirming the guard goes red, then restoring. +The live run was repeated so the recorded hashes describe the current sources. + +## Recorded from the review, not disputed + +claude checked the two `gateWriterTokenPath` call sites and found the asymmetry deliberate: the env +branch returns early and sources every field from env, because it exists to point a spawn at a +*different* server — and a different server's gate-writer token lives at a different path, so +falling back to the committed file there would be the defect rather than the fix. + +## Not changed + +Neither lane raised a blocking issue. All five phase 6 acceptance criteria are closed. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_7-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_7-iter1-rebuttals.md new file mode 100644 index 000000000..a734ce5f9 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_7-iter1-rebuttals.md @@ -0,0 +1,42 @@ +# Phase 7, iteration 1 — what the two lanes said and what changed + +**Both lanes APPROVE at HIGH confidence.** No blocking issues. Claude raised four non-blocking +notes; all four are acted on rather than argued with, because all four are right. + +## 1. `data-codev-builder-count` was two derivations of one fact — FIXED + +> `data-codev-builder-count` is populated from the render-side run scan, not from +> `entry.builderCount`. Two independent derivations of one fact, and the Playwright assertion +> validates only the scan. + +Correct, and it is the shape of defect this project has shipped five times: a check that can only +ever agree with the thing it is checking. The attribute now comes from `entry.builderCount`, so the +DOM carries what the GROUPING decided while the rows beneath it carry what was DRAWN — and the +Playwright test asserting `data-codev-builder-count="3"` alongside three builder rows is a real +cross-check instead of a tautology. + +## 2. The tree covers the Active section only, undocumented outside code comments — RECORDED + +> Deliberate and handled, but undocumented outside code comments. + +Recorded in `codev/reviews/250-t3code-front-end-customization.md` under "The tree covers the Active +section only, and phase 8 inherits that", with the consequence spelled out: a phase that assumes +every Codev thread is in the tree is wrong for any thread the user has pinned, snoozed or settled. + +## 3. No package.json script for the spec-250 Playwright config — ADDED + +`packages/codev` gains `test:e2e:spec250`. The command previously existed only in a config comment +and a skip string, which is the wrong place for the one thing a reader needs to run. + +## 4. `props.projectTitle ?? props.codevRoleLabel ?` reads ambiguously — PARENTHESISED + +`??` does bind tighter and the expression was correct. Parenthesised anyway: a reader who has to +check an operator precedence table to know whether a line is a bug is paying a cost the parentheses +would have saved. + +## What neither lane raised + +Neither lane questioned the `alsoVisible` / `parent-elsewhere` addition, the decision to keep +`parent-not-architect` outranking section membership, or the choice to leave orphans outside the +project headings. Recorded so a later reader knows those went unchallenged rather than unnoticed — +they are in the diff both lanes said they read. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_8-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_8-iter1-rebuttals.md new file mode 100644 index 000000000..fa96aaa03 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_8-iter1-rebuttals.md @@ -0,0 +1,43 @@ +# Phase 8, iteration 1 — what the two lanes said and what changed + +**Both lanes APPROVE at HIGH confidence.** opencode raised no issues. Claude raised one, and it is +a confirmation rather than a suspected break. + +## The fork suite result after the last three commits — CONFIRMED GREEN + +> The thread log records "Fork web suite 2916 → will re-run" and never states a post-fix number. +> Three commits landed after that line. + +Correct, and the log was the problem rather than the code: the web suite HAD been run against that +source, but before those commits were made, and a number stated against a source and not a commit is +a number nobody can check. Re-run at the pin the phase closes on: + +``` +$ git -C "$T3CODE_FORK_ROOT" rev-parse --short HEAD +efadf838c +$ npx vp run --filter @t3tools/contracts --filter t3 --filter @t3tools/web typecheck # exit 0 +$ cd apps/web && npx vp test run +Test Files 284 passed (284) + Tests 2916 passed (2916) +``` + +The Codev suite was already run after `efadf838c414` — 7370 + 180 passed, 54 skipped, 0 failed — +and `porch done` re-ran build and tests itself on the same tree. + +The opencode lane checked the same worry statically instead of asking for a run, and reached the +same answer: no dangling `dotClass` reference survives the marker change, `resolveCodevGatePill` +returns the two-field shape the test now asserts, and both `codevGateMarker` render sites are +intact. + +## What neither lane disputed + +Neither questioned writing the fixture's gate through the server-provisioned gate-writer credential +rather than widening a scope, the decision to leave the `Archit…` clip with the gate name and title +intact, or placing the panel outside `ComposerBannerStack`. opencode named the last two explicitly +as correct; recorded here so a later reader knows they went unchallenged rather than unnoticed. + +## Raised by the architect during the phase, not by a lane + +Two changes came from the architect's review of the screenshots: the terminal excerpt gained a +caption, and the row marker became a gavel plus the gate name after both `Gate: ` placements +clipped something at ~230px. Both are in `codev/reviews/250-t3code-front-end-customization.md`. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-phase_9-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_9-iter1-rebuttals.md new file mode 100644 index 000000000..0f724482f --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-phase_9-iter1-rebuttals.md @@ -0,0 +1,69 @@ +# Phase 9, iteration 1 — what the two lanes said and what changed + +**Claude APPROVE/HIGH, opencode COMMENT/HIGH.** The lanes disagreed on severity, so the stricter +one is binding — every point from both is acted on, and one of them was a real defect neither the +tests nor the screenshots could have caught. + +## 1. The grid had no in-app entry point — FIXED, and the test was complicit + +> `/codev-builders` exists. Nothing in the sidebar or chrome links to it. Tests `page.goto` the +> path. A user in t3code cannot find the watch view. + +The binding one. A route nobody can navigate to is a feature that does not exist, and **the test +was part of the problem**: `page.goto` proves the route renders and says nothing about whether +anyone can reach it. The sidebar has a "Builders" link now, gated on `hasCodevHierarchy` so a +workspace with no Codev agents sees nothing new — the same rule the tree follows — and the e2e +clicks it instead of typing the URL. At 390 that means opening the drawer, tapping, and closing it +again, which is the path a person actually walks. + +## 2. The width was measured two ways — FIXED + +> Mount uses `getBoundingClientRect` (border box). `ResizeObserver` then writes `contentRect` +> (padding already gone). `contentWidth` subtracts `PAGE_PADDING * 2` again. + +Correct, and it is this project's recurring shape: two derivations of one fact. The named viewports +still landed on 3 and 4 columns, which is exactly what makes it dangerous — near a column boundary +the layout would flip after the first observe and no existing assertion would have moved. The +padding lives on an inner wrapper now, so the observed box has none and both paths report the same +number. + +## 3. Orphans were dropped from the grid — FIXED + +> The route takes `kind === "builder"` only. The sidebar keeps them; the watch view does not. + +The phase 7 reasoning, missed one phase later. A builder whose architect was archived is still a +running agent, and a watch view that omits it hides the agent a human is most likely to be hunting +for. They tile. + +## 4. The sidebar is 256px, not 232 — FIXED + +> `THREAD_SIDEBAR_DEFAULT_WIDTH` is 256. Runtime is measured, so the arithmetic still works. + +Every conclusion survives — 1440 open is 1184 of grid and still three columns; 1920 open is 1664 and +still four — but the unit tests were written against numbers I invented rather than read. They use +the measured ones now, and that surfaced something worth recording: **1440 with the sidebar +COLLAPSED fits four columns**, so the architect gets a tile there. That is the rule working rather +than an exception to it — the user made room for a fourth column and 4 + 3 is not ragged — and it is +the same case the architect approved when ruling on the departure from "1920 or wider". + +## 5. Two things the DOM was saying that were not true — FIXED + +`data-codev-architect-placement` read `"strip"` on a page with no architect at all. It reads +`"none"` there now: "no architect" is its own answer. And the header said "N builders" while N+2 +tiles rendered in the multi-architect case. + +## 6. `--codev-pane-body` set and consumed nowhere — FIXED + +The grid published the custom property from `MIN_BODY_PX` and the panes carried a literal +`text-[13px]` — a second copy of a number `layout.ts` owns. The pane inherits the property. + +## 7. The route computed the same grouping twice — FIXED + +One pass, one answer. + +## Not acted on, and why + +**"`BuilderPane` has no props for phase and messages, so phase 10 has to change the component."** +True and intended. The architect ruled that pane content comes from `codev-agent` over the +same-origin proxy in phase 10, with the fork's contract left unextended. Adding empty props now +would be guessing the shape of data this phase cannot fetch. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-plan-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-plan-iter1-rebuttals.md new file mode 100644 index 000000000..32e0bb5bc --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-plan-iter1-rebuttals.md @@ -0,0 +1,306 @@ +# Plan review round 1 — rebuttals + +Three lanes ran: `claude`, `codex` and `opencode`. Porch names two; the third is explained under +[Lane note](#lane-note). + +**All three returned `REQUEST_CHANGES`, and I accepted every finding.** There is nothing in the +disagree column. Two of the findings were errors of mine that would have caused real damage, and +one of the reviews corrected a fix I had just made in response to another — which is the argument +for having run three. + +Every finding was checked against source before I acted on it. Reviewer summaries are evidence, +not ground truth, and one of them turned out to be right about the fault while wrong about the +mechanism. + +--- + +## claude lane + +### 1. Migration 900 would silently disable every future upstream migration — ACCEPTED + +The most important finding in the round, and my error. + +Verified in `node_modules/.pnpm/effect@4.0.0-beta.103/.../unstable/sql/Migrator.js` — the exact +version `pin.json` names. It is a **watermark**, not a set difference: + +```js +const latestMigration = sql`SELECT migration_id, name, created_at FROM ${sql(table)} ORDER BY migration_id DESC` // :78 +if (currentId <= latestMigrationId) { continue; } // :121 +``` + +Registering 900/901 makes the watermark 901, so upstream's later 043, 044, … arrive **below** it +and are skipped while the migrator logs that the schema is current. My mitigation ("number far +above upstream's range") converted a loud collision into silent schema divergence, and my proposed +guard test — fail if upstream reaches 900 — asserted the inverse of the right invariant. + +**Changed.** Codev's columns never enter `migrationEntries`. A guarded, idempotent +`PRAGMA table_info` + `ALTER TABLE … ADD COLUMN` runs from a layer sequenced after `MigrationsLive` +and never touches `effect_sql_migrations`. + +**Found while fixing it:** this is upstream's own idiom, not an invention. +`042_ProjectionThreadLinkedPullRequest.ts` is exactly that shape, and `021`, `022`, `032`, `033`, +`034`, `035`, `039` and `040` all do the same. The only difference is where it is invoked from. + +This contradicted a spec **Assumption** ("the added columns follow that mechanism"). I raised it +rather than changing it quietly; the architect ruled on 2026-08-30 to stay out of the registry, and +the spec's risk row was amended under their authority as approver. The accepted cost — our columns +are absent from upstream's migration history — is mitigated by a named start-up signal, +`CODEV_SCHEMA_GUARD_APPLIED` / `CODEV_SCHEMA_GUARD_NOOP`. + +### 2. Phase 1 breaks `spec-146-t3-contract.test.ts:254` — ACCEPTED + +Read the test: it compares the cold-start evidence's mtime against `t3-server.mjs` and `smoke.mjs`, +and phase 1 edits `t3-server.mjs`. The test is doing its job — it exists so a harness change cannot +ride on stale evidence. + +**Changed.** Re-collecting the evidence against a live pinned server is now a phase-1 deliverable, +with the command in the phase. The assertion is not loosened. + +### 3. Phase 5 breaks `:231` — ACCEPTED + +`expect(evidence.pinnedCommit).toBe(pin.commit)`, and phase 5 moves `pin.commit` to the fork head. +My claim that the spec-146 suite "still passes unchanged" was false. + +**Changed.** Re-scoped to `pin.upstreamBase`. The evidence describes the **upstream** harness +starting the **upstream** server, so `upstreamBase` is the commit it should be checked against. +Re-collecting it against the fork would have been the wrong fix: it would silently change what the +evidence is evidence *of*, and spec 146's criteria about the pinned harness would stop meaning what +they said. + +### 4. Seven `T3CODE_ROOT` readers, not three — ACCEPTED + +Correct. I missed `packages/t3-client/live/integration.mjs:77` because my first grep was truncated +at 20 lines — my error, not a subtle one. + +**Changed.** All seven are now assigned to an identity in a table in phase 1, and +`generate.mjs:78`'s head check moving to the fork root is called out as the load-bearing edit +rather than left implicit. + +### 5. Criterion 8b passed by construction — ACCEPTED, and now moot + +Verified: `Migrator.js:142` wraps the run in `sql.withTransaction`, and SQLite DDL is transactional, +so a kill would have rolled everything back and the criterion would have been met without the code +being careful. + +Moot after finding 1: outside the migrator there is no wrapper. Two `ALTER`s are two atomic steps, a +kill between them leaves exactly one column added, and the `PRAGMA table_info` guard is what makes +the next start finish the job. The kill test now discriminates, and the plan says why. + +### 6. Phase 10 understates both modules it ports — ACCEPTED + +Verified all three sub-points: + +- `client-static.ts:329-337` builds the strip set from `HOP_BY_HOP` **plus the tokens named by the + request's own `Connection` header**. A port hardcoding the fixed list satisfies the sentence + "strips hop-by-hop headers" and is wrong. +- `approval.ts` has **four** outcomes, not three: `sessionEnded` (`:79`, `:126`, `:135`) is distinct + from `unconfirmed` and is ordinary — sessions idle out at 30 minutes. Folding it into refusal + tells someone their approval failed when they need to re-open a session. +- `approval.ts:300-316` forbids manufacturing `approvedAt`, `machine` and `sessionId` client-side. + Criterion 4 asks porch to record exactly those three, so a port that fills them locally passes a + naive assertion while recording fiction. + +**Changed.** All four are deliverables now, plus the two proxy failure signals and the named +pairing ceremony. + +### 7. Fork-only phases have no artifact here — ACCEPTED + +**Changed.** Phases 2, 3, 4, 7, 8 and 9 each log their fork commit in `tools/t3-fork/FORK.md`, +which is their only artifact in this repository and what makes them committable here. + +### 8. No abandonment path — ACCEPTED + +The spec keeps `apps/client` as the fallback and never says how to fall back to it. + +**Changed.** `FORK.md` gains it in phase 5: revert `pin.commit` to `upstreamBase`, regenerate, +re-verify. + +### 9. Fork suite scope unbounded — ACCEPTED + +**Changed.** Per-phase runs are scoped to the packages that phase touches; one full run at +phase 11. + +--- + +## codex lane + +### 1. Gate revision semantics not implementable as written — ACCEPTED + +A genuine self-contradiction. I wrote both "`codev-agent` sends no revision, the server allocates" +and "a write carrying a lower revision is rejected", plus a criterion 10 that delivers one. If no +write ever carries a revision, there is nothing to reject. + +**Changed.** `revision` is optional: absent means allocate `gateRevision + 1`; present means it must +**exceed** the mark or be refused `CODEV_GATE_REVISION_STALE`. Equal is refused, not treated as +idempotent. Criterion 10's stale write is the second case. + +### 2. `codev:gate-write` unenforceable at the referenced point — ACCEPTED + +Verified: `RpcAuthorization.ts:24` maps `ORCHESTRATION_WS_METHODS.dispatchCommand` **as a whole** to +`AuthOrchestrationOperateScope`. It scopes methods, not command types. + +**Changed.** Gate writes travel their own RPC method with its own row in that map. See the opencode +section — this fix was itself incomplete. + +### 3. Phase 6's project map would be dead code — ACCEPTED + +Verified: `packages/codev/src/agent-farm/thread-backend.ts:442-450` already resolves a project by +comparing `canonicalWorkspaceKey(project.workspaceRoot)`, and `:785-818` calls `createProject`, all +inside `ensureThreadBackendReady`. My new `t3-project-map.ts` would have sat unused. + +**Changed.** Phase 6 extends that path. Also recorded: `project.create` is **not** idempotent +(`:382`, t3code refuses a second active project for a workspace root), so the existing single-flight +guard is load-bearing and stays. The publish cycle is named too — `status-reader.ts` is a reader with +no cycle of its own, so the phase says what drives the publisher. + +### 4. Persistence work named too few modules — ACCEPTED + +All four exist and are now named in phases 2 and 4: `persistence/Services/ProjectionThreads.ts`, +`persistence/Layers/ProjectionThreads.ts`, `orchestration/Layers/ProjectionPipeline.ts`, +`orchestration/Layers/ProjectionSnapshotQuery.ts`. + +**Changed.** Start-up layer ordering is also stated and asserted by a test — +`SqliteClient` → `MigrationsLive` → `CodevSchemaGuardLive` → projections. A repository query running +before the guard would read a table without our columns, and that failure would look like missing +data rather than a boot-order bug. + +### 5. Proxy has no upstream-target trust boundary — ACCEPTED + +A server proxy forwarding to a browser-named origin is an SSRF primitive, and a route-path allowlist +constrains the path, not the host. + +**Changed.** The target is chosen from a **server-held** allowlist by id, never by URL. Scheme and +address rules enforced server-side, no credentials in the URL, redirects not followed, absolute URLs +refused rather than normalised. Adversarial tests named. + +--- + +## opencode lane + +This lane found a hole in the fix I had just made for codex's finding 2, which is why it was worth +keeping all three. + +### 1. `codev.gateWrite` is never registered on the wire — ACCEPTED + +Verified: `RpcAuthorization.ts:130` is `satisfies Readonly>`, +and `WsRpcMethod` derives from `WsRpcGroup` in `packages/contracts/src/rpc.ts` — which `pin.json` +**deliberately excludes** from the closure. Adding only the authorization row is a type error. + +**Changed.** Phase 4 names all four registration points: `Rpc.make` and `WsRpcGroup` membership in +`rpc.ts`, the method constant, the handler key in `ws.ts:1174`, then the authorization row. + +### 2. Gate commands must stay out of the client command unions — ACCEPTED + +`ClientOrchestrationCommand` and `DispatchableClientOrchestrationCommand` +(`orchestration.ts:935-987`) *are* the `dispatchCommand` payload. Putting the gate commands there +would hand gate-writing to every `orchestration:operate` holder and bypass the new scope entirely — +undoing the whole point of the phase. + +**Changed.** Stated as a deliverable, with `ThreadSessionSetCommand` recorded as the internal-only +precedent. + +### 3. Phase 5 would not vendor the method — ACCEPTED + +Verified: `generate.mjs:335` iterates `Object.entries(pin.methods)`, not `OrchestrationRpcSchemas`, +so a method in the schemas map but absent from `pin.methods` is silently ignored. + +**Changed.** `codev.gateWrite` is added to `pin.json`'s `methods`, following the `vcs.*` precedent — +those entries exist there for exactly this reason. + +### 4. `acquire` still keys off `pin.commit` — ACCEPTED + +**The most damaging finding in the round.** `acquire()` does +`gitIn(t3Root, 'checkout', '--detach', pin.commit)` (`t3-server.mjs:94`) against `T3CODE_ROOT`, the +read-only upstream clone. Once phase 5 moves `pin.commit` to the fork head, that tries to check a +fork SHA out into the clone the spec keeps pinned at `upstreamBase`. `start` (`:389`) and `status` +(`:663`) compare the same way, and both `smoke.mjs:156` and `live/integration.mjs:196` call +`acquire` — so it fires from an ordinary test run, not a deliberate invocation. + +I had rewired only `verify`, which is the one verb that does not write. + +**Changed.** Phase 1 rewires `acquire`, `start` and `status` to `upstreamBase`. + +### 5. Gate-write credential path unnamed — ACCEPTED + +Verified: `AuthEnvironmentScope` is a closed `Schema.Literals` of eight (`auth.ts:84-93`), and +`auth.ts` is on the vendored closure, so this is a contract change phase 5 regenerates. + +**Changed.** The phase names the issuance API and the credential's on-disk path, and adds two +exclusions as deliverables: the scope must not enter `AuthStandardClientScopes` (`auth.ts:98-104`) +nor the token allowlist (`apps/server/src/auth/http.ts:265-274`). Either would grant gate-writing to +exactly the callers the scope exists to exclude. + +### 6. Leftover revision return path — ACCEPTED + +Fixed: the allocated revision returns on the new RPC's own response, not through `dispatchCommand`. +That sentence predated the gate commands moving off `dispatchCommand`. + +--- + +## Two findings of my own, raised while verifying the above + +Recorded here because they change the plan and neither came from a reviewer. + +### The CSP claim was false, in my plan and in the spec + +Phase 10 and the spec's Security section both said `connect-src 'self'` "stays closed". Verified in +the fork's tree: t3code sets `Content-Security-Policy` on `.svg` asset responses only +(`apps/server/src/http.ts:51,62` — `default-src 'none'; style-src 'unsafe-inline'; sandbox`), and +`apps/web/index.html` carries no CSP meta tag. **There is no page-level CSP and no `connect-src` +directive to keep closed.** + +The same-origin design is unchanged and still correct — the proxy means no cross-origin request is +*made* — but the guarantee is **structural, not enforced**, and must not be written as enforced. The +test now records every request the page issues under Playwright instead of parsing a header that is +never sent. Adding a page-level CSP is recorded as an explicit non-goal here: it changes how every +t3code page loads, far wider than the spec's "keep the diff narrow" constraint. + +The architect confirmed this is an error in the spec and is fixing the Security section. + +### Three phases planned tests with a tool the fork does not have + +Phases 7, 8 and 9 all said "verified under Playwright". **t3code has no `playwright` in any +`package.json`**, and `apps/web`'s entire test script is `vp test run --passWithNoTests --project +unit` with `@effect/vitest` as its only test dependency. Criteria 5 and 5b are browser +*measurements* — pane bounding boxes in CSS px, computed font size — which a vitest unit test cannot +produce; it proves the arithmetic in `columnsFor`, not that the rendered pane is 340px wide inside +t3code's chrome. + +**Changed.** The harness lives in this repository, which already carries `@playwright/test ^1.58.0` +in `packages/codev`, `apps/client`, `apps/v2` and `packages/artifact-canvas`, and drives the fork's +dev server over HTTP. Two reasons, in order: it keeps the fork diff narrow, which the spec names as +its unmergeability mitigation, and the criteria are Codev's so the tests that close them belong in +Codev's CI. Cost recorded: those tests need a running fork, so they are gated, and **a skip is +reported as a skip, never counted as a pass**. + +--- + +## Lane note + +Porch names `claude` and `opencode`. A third lane, `codex`, was added while the opencode lane was +being diagnosed and was kept once it worked — three independent reviews rather than two. + +The opencode lane failed five times before producing this review, and **part of my report on why was +my own measurement error**, corrected here rather than left standing. + +- **Real cause.** opencode auto-rejects `external_directory` permission requests. This plan cites + `/Users/chris/dev/t3code` throughout, so the lane died on its first read outside the workspace. + `runOpencodeConsultation` sets `OPENCODE_PERMISSION` unconditionally + (`commands/consult/index.ts:1806`) and `OPENCODE_READ_ONLY_PERMISSION` (`:1647`) covers only + `edit`, `write`, `patch` and `bash` — `external_directory` is not in it, so it falls back to + opencode's default of ask, which auto-rejects when non-interactive. +- **My error.** I reported every failure as "exit 0 with no verdict". Every one of those runs was + invoked as `consult … 2>&1 | tail -15`, and in a pipeline the reported exit code is the *last* + command's — so the `0` was always `tail`'s. Run clean, the same command returns **exit code 1** and + names the cause on stderr. The lane had been hard-failing correctly the whole time, exactly as its + contract says (`index.ts:1693`; #20 records why a silent lane is worse than a loud one). Filed + under #261; the "porch spawns consult without the env var" theory in that issue describes a + mechanism that does not exist — porch is a pure planner and emits the command for the builder to + run. +- **What made it work.** `OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"allow"}}'` + scoped to the invocation, and no pipe. 298s, full review, exit 0. No global config was edited. + +**So this plan's opencode review ran under a non-default permission**, and a reader should know that +rather than assume a default lane produced it. The grant is broad — it allows *any* external +directory for that process, not only the t3code clone — and is accepted here because the lane is +read-only review on this machine. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/250-review-iter1-rebuttals.md b/codev/projects/250-t3code-is-the-front-end-privat/250-review-iter1-rebuttals.md new file mode 100644 index 000000000..538e324de --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/250-review-iter1-rebuttals.md @@ -0,0 +1,98 @@ +# Review phase, iteration 1 — the PR review, and what changed + +**Claude: APPROVE. opencode: COMMENT.** No blocking findings from either lane. + +Before either could run, both refused: `gh pr diff 266` returns **HTTP 406, the diff exceeded the +maximum number of lines (20000)**, and this PR is 43,714 lines. Both printed the right refusal — +"a reviewer cannot tell an empty diff from a failed fetch" — and both **exited 0** while printing +it. Filed as **#267**, with the two halves separated: the missing `pr-diff` fallback, and a refusal +that spells itself like success to a caller checking the exit status. + +The cap is on the API, not the content, and consult already writes the diff to a temp file for the +model to read rather than inlining it. A `gh` shim on PATH intercepting `pr diff` only, serving +`git diff origin/main...builder/spir-250`, produced the same 130 changed files +`gh pr view --json changedFiles` reports. `.codev/config.json` was deliberately not edited: it is a +symlink to the shared workspace config, and a `forge.pr-diff` override there would have changed the +forge for every builder and the architect to work around one oversized PR. + +--- + +## 1. The evidence collector test mutated committed files — ACCEPTED, and the rebuttal was too broad + +> `spec-250-evidence-collector.test.ts` mutates committed tracked files and restores in `finally`; +> a killed run leaves a dirty working tree plus a `.spec250-test-backup`, and parallel workers on +> the same paths would race. + +**Accepted.** This was rebutted in phase 11 round 2, and that rebuttal defended a real point with +an argument that covered more ground than it should have. The point that holds: the test's value is +that it drives the collector against its **real** inputs, so copying the fixtures and pointing the +collector at the copies would test a copy. The part that did not hold: that reasoning applies to +**one** of the six tests — `agrees with the committed evidence`, the one asserting that the numbers +committed to this repository still match the runs behind them. The other five work by **damaging** +an input, and a damaged input has no reason to be the committed one. + +**The race is real, and I had not checked for it.** `spec-250-vendoring-identities.test.ts` reads +`codev/research/250-criterion-8b-evidence.json` **in its module body**, at collection time. Vitest +runs test files in parallel workers. So a worker collecting that file while this one held the +mutation would fail on corrupted data, for reasons nothing in its own output would explain — and it +would look like flakiness in a file that has nothing to do with the collector. + +**The fix uses a technique already in this project.** The collector resolves its root from +`import.meta.url`, exactly as `generate.mjs` does, so a **copy of the script under a scratch tree +reads that tree's inputs** — which is how phase 11's drill regenerates the contract without moving +the pin. `withScratchRoot` builds a `mkdtempSync` root, copies the collector and its six inputs +into it, and removes it in `finally`. No flag was added to the tool to suit a test, nothing tracked +is written, and a killed run leaves a temp directory rather than a mutated repository. + +`spawnSync('rm', ['-f', backup])` is gone with the backup file it deleted. + +**Verified capable of failing, and this one needed verifying.** Three of the five refusal tests +assert exit 3, and `MISSING_RUN` — a scratch root missing an input — is also exit 3. So a fixture +that was subtly incomplete would have made them pass for the wrong reason, which is the exact defect +this project hit five times. Removing all five mutations: **5 failed, 1 passed** — every refusal test +fails without its damage, and the happy path still passes, so the collector runs correctly in the +scratch root and nothing passes vacuously. Restored: 6 passed, and 83 passed with +`spec-250-vendoring-identities.test.ts` alongside it. + +## 2. Two unreconciled commit counts — ACCEPTED + +> PR body claims 166 commits; the review says 105 `[Spec 250]` commits. Reconcile or label what +> each number counts. + +**Accepted.** Both numbers were also stale — they were taken against a local `main` that was behind +`origin/main`. The branch carries **167 commits, of which 106 are `[Spec 250]`**; the rest are +`chore(porch)` bookkeeping. Both places now say which they count. + +Claude read this as a digit transposition of 106 into 166 and it was not, but the underlying +complaint was right: two numbers describing the same branch with nothing saying what either counts. + +## 3. `status.yaml` history records 9 rounds, 20 ran — ACCEPTED, filed as #268 + +**Accepted, and the pattern is sharper than "a recording gap".** Cross-referencing every recorded +round against its verdicts: **a round is recorded if and only if at least one lane did not +approve.** No exception in either direction across 20 rounds. + +- Phases 7, 8 and 9 are absent **entirely** — they are the three phases where both lanes approved + on round 1. +- Every terminal, approving round is missing: phase_1 iter2, phase_2 iter2, phase_3 iter2, phase_4 + iter3, phase_5 iter2, phase_6 iter2, phase_10 iter2, phase_11 iter2. +- phase_4 iter2 is the one middle iteration recorded, and it is the one that still carried a + `REQUEST_CHANGES`. + +So a phase reviewed cleanly is indistinguishable from a phase never reviewed, and `history` +understates review effort *selectively* — biased toward the phases that went badly. Filed as +**#268**. `status.yaml` was not hand-edited. + +## Not changed + +**"The product change lives in the private fork and is not reviewable from this diff."** Correct +and by design — ruled at plan time, stated in the PR body's first section. The fork's own commits +are on `pseudoseed/t3code@codev` and the screenshots are at `docs/codev/spec-250/` there. + +**Criterion 6 UNMET, criterion 9 met only under the amendment, #264 open on the approval path** — +opencode's three key issues. All three are already stated in the review, the evidence document and +the PR body, in those words. Recorded as confirmations, not findings. + +**Claude could not verify branch freshness, the commit count, or a live test run** — it had no +shell. Checked here instead: the branch is at `origin/main` + 167, and the counts above come from +`git log` and `gh pr view`, not from memory. diff --git a/codev/projects/250-t3code-is-the-front-end-privat/status.yaml b/codev/projects/250-t3code-is-the-front-end-privat/status.yaml new file mode 100644 index 000000000..73e9a7725 --- /dev/null +++ b/codev/projects/250-t3code-is-the-front-end-privat/status.yaml @@ -0,0 +1,200 @@ +id: '250' +title: t3code-is-the-front-end-privat +protocol: spir +phase: review +plan_phases: + - id: phase_1 + title: Two-identity vendoring harness + status: complete + - id: phase_2 + title: Thread hierarchy in the fork's contract and projection + status: complete + - id: phase_3 + title: Hierarchy integrity refused at write time + status: complete + - id: phase_4 + title: Porch gate block with a server-allocated revision + status: complete + - id: phase_5 + title: Vendored contract regenerated from the fork + status: complete + - id: phase_6 + title: Hierarchy and gate state published by porch-driver and codev-agent + status: complete + - id: phase_7 + title: Workspace to architect to builder sidebar + status: complete + - id: phase_8 + title: Gate rendering in t3code + status: complete + - id: phase_9 + title: Builder tiling + status: complete + - id: phase_10 + title: Approval from t3code over the same-origin proxy + status: complete + - id: phase_11 + title: 'Acceptance run: tailnet iPad and the rebase drill' + status: complete +current_plan_phase: null +gates: + spec-approval: + status: approved + approved_at: '2026-08-30T19:30:31.598Z' + approval: + authorization: pre-approved-artifact + approved_at: '2026-08-30T19:30:31.598Z' + machine: chriss-MacBook-Pro.local + caller: pre-approval frontmatter on codev/specs/250-t3code-front-end-customization.md + plan-approval: + status: approved + requested_at: '2026-08-30T20:06:28.609Z' + request: + question: >- + Approve the 11-phase plan for spec 250, which now deviates from the spec in three places you have ruled on or + been told about? + choices: + - label: Approve as written + consequence: >- + Phase 1 begins: gh repo fork pingdotgg/t3code creates a PUBLIC fork under pseudoseed, clones it to + /Users/chris/dev/t3code-codev, adds upstreamBase to pin.json, rewires verify/acquire/start/status to two + identities, splits classify-churn into two ranges, and re-collects the spec-146 cold-start evidence. That + fork creation is outward-facing and not quietly undoable. + recommended: true + - label: Approve, but I create the fork myself first + consequence: >- + Same plan, but phase 1 waits until you have run gh repo fork and told me the fork exists. Removes the only + outward-facing act from my hands; costs a round trip. + - label: Send back for changes + consequence: >- + Name what to change. The plan has had three review lanes and two rounds of my own verification, so a fourth + pass has diminishing returns unless you disagree with a specific decision. + terminalExcerpt: |- + RUNNING CHECKS... + plan_exists OK + has_phases_json OK + min_two_phases OK (11 phases) + + Reviews: claude REQUEST_CHANGES, codex REQUEST_CHANGES, opencode REQUEST_CHANGES + All findings accepted; nothing in the disagree column. + + Three deviations from the spec as written: + 1. Migration mechanism - you already ruled on this; spec risk row amended. + 2. Spec Security section asserts connect-src 'self' stays closed. t3code sets + CSP on .svg responses only (http.ts:51,62); there is no page-level CSP. + You said you are fixing the Security section. + 3. Spec test scenarios imply browser verification in t3code. t3code has no + Playwright; apps/web is vitest-only. Harness moved into this repo. + + Most damaging finding caught: acquire() checks pin.commit out into the + read-only upstream clone (t3-server.mjs:94), called from smoke.mjs:156 and + live/integration.mjs:196, so it would have fired from an ordinary test run + once pin.commit named the fork. + approved_at: '2026-08-30T20:09:05.175Z' + approval: + authorization: flag-only + approved_at: '2026-08-30T20:09:05.175Z' + machine: chriss-MacBook-Pro.local + caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) + pr: + status: approved + requested_at: '2026-08-31T12:29:13.290Z' + request: + question: 'Merge PR #266, or rule on the pane screenshots first? Both review lanes cleared it and criterion 6 closes UNMET.' + choices: + - label: Open the phase-10 screenshots, then merge + consequence: >- + 12 screenshots at docs/codev/spec-250/phase-10/ in the fork at /Users/chris/dev/t3code-codev, plus + phase-7/8/9 at 390, 1440x900 and 1920. The tests and measurements pass; what the panes LOOK like has never + been ruled on, and a green Playwright run cannot judge that. This is the only part of the spec no machine + has signed off. + recommended: true + - label: Merge now on the two lane verdicts + consequence: >- + claude APPROVE, opencode COMMENT, no blocking findings. Merges with `gh pr merge 266 --merge` (never + squash). The pane appearance goes unruled and becomes a follow-up against a merged branch rather than a + change in this PR. + - label: 'Hold: criterion 6 must be run before merge' + consequence: >- + Needs an iPad on the tailnet, which was not available for the whole project. The 16-step runbook is at + codev/resources/250-ipad-acceptance-runbook.md and every step not needing the device was verified. Holding + blocks the PR on hardware, not on code. + - label: 'Hold: criterion 9''s literal wording must be met' + consequence: >- + Would mean advancing pin.json onto a new upstream base, which the phase-11 amendment forbids because every + spec 146 and 236 result tied to 082e6ea52186 stops being re-runnable. The rebase stops at commit 6 of 42; + the drill measured that rather than performing it. + terminalExcerpt: >- + porch next 250 -> gate_pending: pr + + 2 of 2 lanes actually reviewed. Did not approve: opencode: COMMENT. + claude: APPROVE + opencode: COMMENT + + codev suite: 1859 passed, 18 skipped, 1 failed -> re-run alone 24 passed (spec-1280 T12 determinism; I committed + during the run) + + collect-spec-250-evidence.mjs --check: evidence is up to date + + + UNMET: criterion 6 (iPad over the tailnet, no device available; runbook written) + + AMENDED: criterion 9 met by the procedure completing and reporting, not by a clean rebase + + UNRULED: the pane internals, 12 screenshots in the fork at docs/codev/spec-250/phase-10/ + approved_at: '2026-08-31T12:56:55.750Z' + approval: + authorization: flag-only + approved_at: '2026-08-31T12:56:55.750Z' + machine: chriss-MacBook-Pro.local + caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) + verify-approval: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-30T19:30:16.298Z' +updated_at: '2026-08-31T12:56:56.619Z' +context_refreshes: + - boundary: enter:implement + at: '2026-08-30T20:09:29.526Z' + acknowledged_at: '2026-08-30T20:10:57.176Z' + - boundary: plan-phase:phase_2 + at: '2026-08-30T21:09:20.044Z' + acknowledged_at: '2026-08-30T21:21:49.883Z' + - boundary: plan-phase:phase_3 + at: '2026-08-30T22:46:35.537Z' + acknowledged_at: '2026-08-30T22:47:44.711Z' + - boundary: plan-phase:phase_4 + at: '2026-08-30T23:46:47.337Z' + acknowledged_at: '2026-08-30T23:47:47.970Z' + - boundary: plan-phase:phase_5 + at: '2026-08-31T02:49:02.911Z' + acknowledged_at: '2026-08-31T02:51:24.420Z' + - boundary: plan-phase:phase_6 + at: '2026-08-31T03:38:38.360Z' + acknowledged_at: '2026-08-31T03:38:44.502Z' + - boundary: plan-phase:phase_7 + at: '2026-08-31T05:03:44.179Z' + acknowledged_at: '2026-08-31T05:07:15.264Z' + - boundary: plan-phase:phase_8 + at: '2026-08-31T06:20:14.961Z' + acknowledged_at: '2026-08-31T06:23:08.713Z' + - boundary: plan-phase:phase_9 + at: '2026-08-31T07:49:39.343Z' + acknowledged_at: '2026-08-31T07:51:54.890Z' + - boundary: plan-phase:phase_10 + at: '2026-08-31T09:15:37.962Z' + acknowledged_at: '2026-08-31T09:17:32.814Z' + - boundary: plan-phase:phase_11 + at: '2026-08-31T10:38:24.893Z' + acknowledged_at: '2026-08-31T10:40:39.570Z' + - boundary: enter:review + at: '2026-08-31T11:47:09.000Z' + acknowledged_at: '2026-08-31T12:00:11.403Z' +pr_history: + - phase: review + pr_number: 266 + branch: builder/spir-250 + created_at: '2026-08-31T11:58:28.994Z' +pr_ready_for_human: false diff --git a/codev/research/146-harness-coldstart-evidence.json b/codev/research/146-harness-coldstart-evidence.json index 3eacd5b8e..e197a7743 100644 --- a/codev/research/146-harness-coldstart-evidence.json +++ b/codev/research/146-harness-coldstart-evidence.json @@ -1,11 +1,11 @@ { "criterion": "Phase 1: harness brings up a live pinned server, twice, with a real dispatched command", - "pinnedCommit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "upstreamCommit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "pinnedCliVersion": "0.0.36", "runs": [ { "run": 1, - "startedAt": "2026-08-30T02:21:58.069Z", + "startedAt": "2026-08-31T04:17:59.952Z", "serverRuntime": { "node": "/opt/homebrew/Cellar/node/26.4.0/bin/node", "version": "26.4.0", @@ -20,11 +20,11 @@ "dispatchSucceeded": true, "portFreeAfterStop": true, "ok": true, - "durationMs": 6072 + "durationMs": 6276 }, { "run": 2, - "startedAt": "2026-08-30T02:22:04.141Z", + "startedAt": "2026-08-31T04:18:06.228Z", "serverRuntime": { "node": "/opt/homebrew/Cellar/node/26.4.0/bin/node", "version": "26.4.0", @@ -39,7 +39,7 @@ "dispatchSucceeded": true, "portFreeAfterStop": true, "ok": true, - "durationMs": 5966 + "durationMs": 6019 } ], "allRunsPassed": true, diff --git a/codev/research/146-phase10-live-evidence.json b/codev/research/146-phase10-live-evidence.json index 60a79d6f3..0e603d254 100644 --- a/codev/research/146-phase10-live-evidence.json +++ b/codev/research/146-phase10-live-evidence.json @@ -2,7 +2,7 @@ "_comment": "Spec 146 Phase 10. Generated by tools/t3-server/collect-phase10-evidence.mjs from the runs in tools/t3-server/.runtime-runs/. Do not hand-edit: spec-146-phase-10-full-protocol.test.ts asserts this against the runner that produced it.", "recordedAt": "2026-08-30T09:55:18.227Z", "server": { - "pinnedCommit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "upstreamCommit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "pinnedCli": "0.0.36", "interpreter": "Node 26.4.0 (outside t3code engines.node ^24.13.1; the harness emits its ADVISORY and continues)", "bind": "127.0.0.1 only, one server and one data directory per run" diff --git a/codev/research/250-criterion-8b-evidence.json b/codev/research/250-criterion-8b-evidence.json new file mode 100644 index 000000000..de88cfc6a --- /dev/null +++ b/codev/research/250-criterion-8b-evidence.json @@ -0,0 +1,39 @@ +{ + "criterion": "Spec 250 criterion 8b: the server is killed partway through applying the Codev columns and the resulting database still opens against the pre-fork server binary.", + "preForkCliVersion": "0.0.36", + "upstreamBase": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "forkRoot": "/Users/chris/dev/t3code-codev", + "forkCommit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "dbPath": "/Users/chris/dev/codev-1455/.builders/spir-250/tools/t3-server/.runtime/data/userdata/state.sqlite", + "steps": { + "preForkServerCreatedDatabase": true, + "columnsBeforeGuard": [], + "childKilledBySignal": "SIGKILL", + "columnsAfterKill": [ + "codev_role" + ], + "halfApplied": true, + "preForkServerOpensHalfApplied": true, + "guardResume": { + "added": [ + "codev_parent_thread_id", + "codev_gate_json", + "codev_gate_revision" + ], + "present": [ + "codev_role" + ] + }, + "guardSawWhatTheCrashLeft": true, + "guardFinishedTheJob": true, + "columnsAfterResume": [ + "codev_role", + "codev_parent_thread_id", + "codev_gate_json", + "codev_gate_revision" + ], + "schemaComplete": true, + "preForkServerOpensFullyApplied": true + }, + "passed": true +} diff --git a/codev/research/250-hierarchy-wire-evidence.json b/codev/research/250-hierarchy-wire-evidence.json new file mode 100644 index 000000000..9a8dd1322 --- /dev/null +++ b/codev/research/250-hierarchy-wire-evidence.json @@ -0,0 +1,83 @@ +{ + "_comment": "Spec 250 phase 6. Generated by packages/t3-client/live/spec-250-hierarchy.mjs against a live FORK server started with `t3-server.mjs start-fork`. Do not hand-edit.", + "recordedAt": "2026-08-31T12:47:14.034Z", + "forkRoot": "/Users/chris/dev/t3code-codev", + "forkCommit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "port": 3830, + "algorithm": "sha256", + "sourceHashes": { + "packages/t3-client/live/spec-250-hierarchy.mjs": "101afb9c5d84b01d93ba6966f5f40ebe6e02414b3d23b95409e67de8ce302452", + "packages/t3-client/src/envelope.ts": "43eb01275f7d87c9a4d61746533ae16518e7dc88a7c17ceb310834796f5fa588", + "packages/t3-client/src/client.ts": "bb75f0b6ae2b194fbd29dd63477fc89c059548af463d3773ef8c2ba295d49c70", + "tools/t3-server/t3-server.mjs": "da65d8cdda7e2721a345768eea62eb2fdc8a30acfe78a40306d8e93522fdf8df" + }, + "observed": { + "parent-not-found": { + "kind": "refused", + "tag": "CodevHierarchyInvalidError", + "reason": "parent-not-found", + "parentThreadId": null + }, + "parent-not-architect": { + "kind": "refused", + "tag": "CodevHierarchyInvalidError", + "reason": "parent-not-architect", + "parentThreadId": null + }, + "parent-in-other-project": { + "kind": "refused", + "tag": "CodevHierarchyInvalidError", + "reason": "parent-in-other-project", + "parentThreadId": null + }, + "parent-is-self": { + "kind": "refused", + "tag": "CodevHierarchyInvalidError", + "reason": "parent-is-self", + "parentThreadId": null + } + }, + "claims": [ + { + "name": "an architect thread is accepted with a role and no parent", + "passed": true, + "detail": "{\"kind\":\"accepted\"}" + }, + { + "name": "a builder thread is accepted with its architect as parent", + "passed": true, + "detail": "{\"kind\":\"accepted\"}" + }, + { + "name": "parent-not-found arrives as a readable reason", + "passed": true, + "detail": "{\"kind\":\"refused\",\"tag\":\"CodevHierarchyInvalidError\",\"reason\":\"parent-not-found\",\"parentThreadId\":null}" + }, + { + "name": "parent-not-architect arrives as a readable reason", + "passed": true, + "detail": "{\"kind\":\"refused\",\"tag\":\"CodevHierarchyInvalidError\",\"reason\":\"parent-not-architect\",\"parentThreadId\":null}" + }, + { + "name": "parent-in-other-project arrives as a readable reason", + "passed": true, + "detail": "{\"kind\":\"refused\",\"tag\":\"CodevHierarchyInvalidError\",\"reason\":\"parent-in-other-project\",\"parentThreadId\":null}" + }, + { + "name": "parent-is-self arrives as a readable reason", + "passed": true, + "detail": "{\"kind\":\"refused\",\"tag\":\"CodevHierarchyInvalidError\",\"reason\":\"parent-is-self\",\"parentThreadId\":null}" + }, + { + "name": "the four reasons are distinguishable from one another", + "passed": true, + "detail": "reasons: [\"parent-not-found\",\"parent-not-architect\",\"parent-in-other-project\",\"parent-is-self\"]" + }, + { + "name": "every refusal carried the CodevHierarchyInvalidError tag", + "passed": true, + "detail": "{\"parent-not-found\":\"CodevHierarchyInvalidError\",\"parent-not-architect\":\"CodevHierarchyInvalidError\",\"parent-in-other-project\":\"CodevHierarchyInvalidError\",\"parent-is-self\":\"CodevHierarchyInvalidError\"}" + } + ], + "passed": true +} diff --git a/codev/research/250-rebase-drill.json b/codev/research/250-rebase-drill.json new file mode 100644 index 000000000..eda728e1f --- /dev/null +++ b/codev/research/250-rebase-drill.json @@ -0,0 +1,153 @@ +{ + "outcome": "conflicts", + "detail": "the rebase stopped on conflicts. That is a measurement, not a failure — it is what the drill is for.", + "conflictedFiles": [ + "apps/server/src/server.test.ts" + ], + "stoppedAt": "3a1780bbf", + "gitSaid": [ + "Auto-merging apps/server/src/orchestration/Layers/ProjectionPipeline.ts", + "Auto-merging apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts", + "Auto-merging apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts", + "Auto-merging apps/server/src/orchestration/projector.test.ts", + "Auto-merging apps/server/src/orchestration/projector.ts", + "Auto-merging apps/server/src/persistence/Layers/ProjectionThreads.ts", + "Auto-merging apps/server/src/persistence/Services/ProjectionThreads.ts", + "Auto-merging apps/server/src/server.test.ts", + "CONFLICT (content): Merge conflict in apps/server/src/server.test.ts", + "Auto-merging apps/server/src/ws.ts", + "Auto-merging packages/contracts/src/auth.ts", + "Auto-merging packages/contracts/src/orchestration.ts", + "Auto-merging packages/contracts/src/rpc.ts", + "Rebasing (1/43)\rRebasing (2/43)\rRebasing (3/43)\rRebasing (4/43)\rRebasing (5/43)\rRebasing (6/43)\rerror: could not apply 3a1780bbf... [Spec 250][Phase: phase_4] feat: gate block with a server-allocated revision", + "hint: Resolve all conflicts manually, mark them as resolved with", + "hint: \"git add/rm \", then run \"git rebase --continue\".", + "hint: You can instead skip this commit: run \"git rebase --skip\".", + "hint: To abort and get back to the state before \"git rebase\", run \"git rebase --abort\".", + "hint: Disable this message with \"git config set advice.mergeConflict false\"", + "Could not apply 3a1780bbf... [Spec 250][Phase: phase_4] feat: gate block with a server-allocated revision" + ], + "wholeSurface": { + "method": "three-way merge of the same two trees, aborted immediately", + "clean": false, + "conflictedFiles": [ + "apps/server/src/server.test.ts", + "apps/web/src/components/Sidebar.logic.ts", + "apps/web/src/components/Sidebar.tsx" + ] + }, + "contractClosure": { + "files": [ + "packages/contracts/src/auth.ts", + "packages/contracts/src/baseSchemas.ts", + "packages/contracts/src/environment.ts", + "packages/contracts/src/git.ts", + "packages/contracts/src/model.ts", + "packages/contracts/src/orchestration.ts", + "packages/contracts/src/providerInstance.ts", + "packages/contracts/src/sourceControl.ts", + "packages/contracts/src/vcs.ts" + ], + "conflicted": [], + "regenerationReachable": true, + "sourceHash": { + "checked": true, + "algorithm": "sha256", + "comparedTo": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "files": { + "auth.ts": "89374198ca06cfc7d21e7080f291de79df34cdc3f9bd9453a1e68895388e16d4", + "baseSchemas.ts": "0fea5d24348912260361716e01cf1c9ce1cd4d456a434471069a9215c4e8f1d7", + "environment.ts": "766021fc016d0a8a45f9523b47f1f3ee4ff1e8564950b58a314bd5bce5082da2", + "git.ts": "0d95b17b6aba4e808951a94c734c398c877e1a334572db659a815d093b3bdabb", + "model.ts": "0749c9085481e3500f156646d4216fb78436764272d97480e059a193fc6982fc", + "orchestration.ts": "fdf0811aac5573ec4b557993fe9392b577a0a1c41a50815f661821027ad8a29b", + "providerInstance.ts": "0a1fea758707473021ff0e340015a464ff1af785a4bb842b8976a574acb0afaf", + "sourceControl.ts": "e58dc3e8612be6e16bf25d9ee13a9c5d8a02807995f086abe544558544cd7f85", + "vcs.ts": "41470d7316088e6fd85710df794076bc6311ca851816856c028a24a8cd4e63db" + }, + "moved": [ + "auth.ts", + "baseSchemas.ts", + "environment.ts", + "orchestration.ts" + ], + "detail": "4 of 9 closure files differ after the merge: auth.ts, baseSchemas.ts, environment.ts, orchestration.ts. The generator therefore reads different bytes; WHAT it emits from them is contractRegeneration, which runs it rather than predicting." + }, + "detail": "every file the generator reads merged without conflict, so regenerating the contract from the rebased tree is not blocked by the rebase. What the regenerated contract would CHANGE is bounded by sourceHash.moved and settled by classify-churn --upstream-movement." + }, + "contractRegeneration": { + "attempted": true, + "source": { + "commit": "704c037d98934892213590fe41801e62cc88cce6", + "kind": "the three-way merge of the same two trees, written by git merge-tree. Conflicted paths carry markers; none of them is in the contract closure." + }, + "method": "a scratch pin naming the merged commit, in a scratch copy of the codegen tool. The real pin.json was neither read nor written for this, and both real checkouts are untouched.", + "interpreter": "v22.22.2", + "generated": true, + "shapeCheckHolds": false, + "artifactsDiffering": [ + "ATTRIBUTION.md", + "schema.json", + "schema.ts", + "source-hash.json", + "types.d.ts" + ], + "shapesDiffering": [ + "schema.json", + "schema.ts", + "types.d.ts" + ], + "embedsCommitId": [ + "ATTRIBUTION.md", + "source-hash.json" + ], + "hashMovedShapesDidNot": false, + "detail": "the contract regenerates, and 3 shape artifacts would change: schema.json, schema.ts, types.d.ts. That is what adopting this base costs, measured rather than predicted." + }, + "startedAt": "2026-08-31T12:47:23.529Z", + "finishedAt": "2026-08-31T12:47:29.073Z", + "upstreamChurn": { + "range": "082e6ea52186..9b2d04317c68", + "commits": 104, + "closureTouching": 5, + "closureFiles": [ + "packages/contracts/src/auth.ts", + "packages/contracts/src/baseSchemas.ts", + "packages/contracts/src/environment.ts", + "packages/contracts/src/git.ts", + "packages/contracts/src/model.ts", + "packages/contracts/src/orchestration.ts", + "packages/contracts/src/providerInstance.ts", + "packages/contracts/src/sourceControl.ts", + "packages/contracts/src/vcs.ts" + ] + }, + "upstreamRoot": "/Users/chris/dev/t3code", + "forkRoot": "/Users/chris/dev/t3code-codev", + "base": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "target": "9b2d04317c68233782e0630464ac86d77d0686f3", + "targetRef": "origin/main", + "forkHead": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "commitsCarried": 43, + "scratch": null, + "watermark": { + "checked": true, + "migrationsDir": "apps/server/src/persistence/Migrations", + "watermarkAtBase": 42, + "addedByUpstream": [ + 43 + ], + "shadowed": [], + "holds": true, + "detail": "upstream added 43, all above the watermark 42 our base leaves — so they run. Codev writes nothing to effect_sql_migrations, which is what keeps that true." + }, + "preserved": { + "upstreamHead": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "upstreamStillAtBase": true, + "upstreamClean": true, + "forkHead": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "forkUnmoved": true, + "forkClean": true, + "pinCommitUnchanged": true + } +} diff --git a/codev/research/250-upstream-movement.json b/codev/research/250-upstream-movement.json new file mode 100644 index 000000000..fffb01b3c --- /dev/null +++ b/codev/research/250-upstream-movement.json @@ -0,0 +1,48 @@ +{ + "mode": "upstream-movement", + "identity": "upstream", + "root": "/Users/chris/dev/t3code", + "range": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6..origin/main", + "total": 5, + "counts": { + "consumed-change-undecidable": 2, + "source-only": 3 + }, + "rows": [ + { + "sha": "3b86ef941c218a000a2ea9920480f469b726f437", + "date": "2026-08-25", + "subject": "fix(app): un-settled threads return to the top of the list (#8231)", + "verdict": "consumed-change-undecidable", + "detail": "orchestration.subscribeThread: unknown (union shape changed; not decidable here)" + }, + { + "sha": "88be5631ff6a48b98ead48fa5af6a2be0a4d63e4", + "date": "2026-08-28", + "subject": "feat(analytics): report connected client platforms (#8481)", + "verdict": "source-only", + "detail": "auth.ts, baseSchemas.ts" + }, + { + "sha": "8f49132214a40c85cf46bf5e3d8ea11c04a9610a", + "date": "2026-08-28", + "subject": "feat(server): accept PDF, ZIP, and other file uploads up to 50MB (#8235)", + "verdict": "consumed-change-undecidable", + "detail": "orchestration.dispatchCommand: unknown (union shape changed; not decidable here)" + }, + { + "sha": "94f1948166620c804937f5548a68f5739d76c9fb", + "date": "2026-08-29", + "subject": "fix(connect): explain DPoP connection failures (#8351)", + "verdict": "source-only", + "detail": "baseSchemas.ts" + }, + { + "sha": "c1c2d5401de5a352cf6722959de076c3da63d233", + "date": "2026-08-29", + "subject": "feat: let an environment publish themes as a file (#8569)", + "verdict": "source-only", + "detail": "environment.ts" + } + ] +} diff --git a/codev/resources/250-acceptance-evidence.md b/codev/resources/250-acceptance-evidence.md new file mode 100644 index 000000000..8e42db2d2 --- /dev/null +++ b/codev/resources/250-acceptance-evidence.md @@ -0,0 +1,310 @@ +# Spec 250 — acceptance evidence + +What was run, on what, with what result. One row per criterion, and **a criterion with no run and +no test says so** rather than borrowing another criterion's evidence. + +Recorded 2026-08-31. Fork at `3786b840e1a4`; upstream preserved at `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6`. + +**10 of 11 met. Criterion 6 is UNMET and says why. Criterion 9 is met under the plan's amended +reading and not under a literal one, and the difference is set out rather than smoothed over.** +Nothing here borrows another criterion's evidence, and nothing that was not run is recorded as +passing. + +## The numbers, regenerated from the runs + + + +_Generated by `tools/t3-server/collect-spec-250-evidence.mjs` from the runs named above._ + +| Measurement | Value | From | +|---|---|---| +| fork pinned at | `2f64a1b0ee2b` | `pin.json` | +| upstream base | `082e6ea52186` | `pin.json` | +| drill target | `9b2d04317c68` (`origin/main`) | rebase drill | +| drill outcome | `conflicts` | rebase drill | +| customization commits carried | 43 | rebase drill | +| sequential rebase stops at | `3a1780bbf`, on apps/server/src/server.test.ts | rebase drill | +| whole conflict surface | **3** files: `apps/server/src/server.test.ts`, `apps/web/src/components/Sidebar.logic.ts`, `apps/web/src/components/Sidebar.tsx` | rebase drill | +| upstream commits in the range | 104 | rebase drill | +| of those, touching the pinned closure | 5 | rebase drill | +| ...classified | 2 `consumed-change-undecidable`, 3 `source-only` | `classify-churn --upstream-movement` | +| regeneration blocked by the rebase | no — zero closure conflicts | rebase drill | +| closure files the rebased tree would change | **4 of 9**: `auth.ts`, `baseSchemas.ts`, `environment.ts`, `orchestration.ts` | rebase drill | +| contract regenerated from the rebased tree | yes, from 704c037d9893 | rebase drill | +| `shape-check` against the vendored contract | **3 shape artifact(s) would change**: `schema.json`, `schema.ts`, `types.d.ts` | rebase drill | +| watermark at base | 42 | rebase drill | +| migrations upstream added | 43 | rebase drill | +| any shadowed (would be skipped) | none | rebase drill | +| preserved upstream unmoved | yes | rebase drill | +| `pin.commit` unchanged by the drill | yes | rebase drill | +| criterion 8b | passed | `criterion-8b.mjs` | +| criterion 11 wire evidence | passed | `spec-250-hierarchy.mjs` | + + + +## The criteria + +| # | What it asks | Evidence | Status | +|---|---|---|---| +| 1 | architect + 3 builders render as a tree, in t3code's own web app | `spec-250-hierarchy.spec.ts` — 9 Playwright tests against the live fork app | **met** | +| 2 | two architects render as two subtrees | `spec-250-hierarchy.spec.ts:265` | **met** | +| 3 | a gated builder shows the gate name and #128's question, from the gate block not the title | `spec-250-gate.spec.ts` — 9 tests, one of which asserts no thread title anywhere contains a gate name | **met** | +| 4 | the gate is approved from t3code and porch records session id, machine and timestamp in `status.yaml`, over `codev-agent`'s capability path | `spec-250-t3code-approval.e2e.test.ts` through the REAL fork server's proxy, ending in a real `status.yaml`; and `spec-250-approval.spec.ts` from a real browser | **met** | +| 5 | six builders at 1440x900, panes ≥340x240, body text ≥13px, measured against t3code's chrome | `spec-250-tiling.spec.ts:136` — measured from the browser's own geometry, not from the component's attribute | **met** | +| 5b | seven panes at 1920 tile 4x2, not 3x3 | `spec-250-tiling.spec.ts:243` | **met** | +| 6 | reached from an **iPad** over the tailnet, no account, no relay, driving a builder to completion | **no run** — no device available; runbook written and verified | **UNMET** | +| 7 | a `role: null` thread appears where it always did and nothing claims it | `spec-250-hierarchy.spec.ts:291` | **met** | +| 8 | an existing database opens against the customized server; added columns read as "not recorded"; a projection rebuilt over a pre-fork event log decodes every historical payload | `apps/server/src/codev/schemaGuard.test.ts` and the projector tests in the fork | **met** | +| 8b | a migration interrupted partway leaves the database openable by the **pre-fork** server — by killing the server, not by argument | `tools/t3-fork/criterion-8b.mjs`, evidence at `codev/research/250-criterion-8b-evidence.json`, `passed: true` at the pin | **met** | +| 9 | the fork rebases onto a later upstream, the contract regenerates and passes `shape-check`, `verify` holds on both identities, and upstream churn is measured and non-zero | this document's next section | **met under the plan's amended reading.** Read literally it is not — see "What the criterion 9 wording asks for and what was run" below | +| 10 | an approved gate cannot be re-displayed by a later write carrying a lower revision | phase 4's revision high-water-mark tests, and the live delivery in phase 6 | **met** | +| 11 | hierarchy integrity refused **at write time**, not rendered in a fallback | `apps/server/src/codev/threadHierarchy.test.ts`, plus the live wire evidence at `codev/research/250-hierarchy-wire-evidence.json`, `passed: true` | **met** | + +## Criterion 9 — the rebase drill, in full + +Run by `tools/t3-fork/rebase-drill.mjs`; machine-readable at `codev/research/250-rebase-drill.json`; +procedure recorded in `tools/t3-codegen/REFRESH.md`. + +**Nothing real moved, and the drill checks it rather than promising it.** `/Users/chris/dev/t3code` +was fetched — remote-tracking refs move, HEAD does not — and re-read afterwards at +`082e6ea52186`, clean. The fork was unmoved and clean. `pin.commit` unchanged. The drill discards +its own result if any of those is false. + +| Question | Answer | +|---|---| +| upstream churn, `082e6ea52186..origin/main` in the **upstream** checkout | every count is in the generated block above, straight from the runs: the totals from the drill, the verdict split from `classify-churn --upstream-movement`. None of them is typed here | +| which two are undecidable | `orchestration.subscribeThread` and `orchestration.dispatchCommand` union shapes — **the two unions our customization adds members to** | +| where does a sequential rebase stop | at **commit 6**, `3a1780bbf` (phase 4's gate block), on `apps/server/src/server.test.ts`. The total it is 6 **of** is the `customization commits carried` row in the generated block above, which rises with every fork commit — it is not restated here, because it was typed as "42" and was wrong the next time the fork moved | +| the whole conflict surface | **3 files of the 35 we modify**: that test, `apps/web/src/components/Sidebar.tsx`, `Sidebar.logic.ts` | +| is regeneration **blocked** by the rebase | **no** — zero conflicts in the pinned closure, so the generator would find its source | +| would the regenerated contract be the one we vendored | **no.** 4 of the 9 closure files come out of the merge with different bytes: `auth.ts`, `baseSchemas.ts`, `environment.ts`, `orchestration.ts`. Measured by hashing the merged tree, not predicted | +| does the watermark still hold | **yes**, and against a real new migration: upstream added `043`, above the `042` our base leaves | +| `shape-check` **at the current pin** | `generate.mjs --check` → `artifacts are up to date`. This describes `3786b840e1a4` | +| does the contract **regenerate** from the rebased tree | **yes.** The generator runs to completion against it, in a second throwaway — see below | +| does the regenerated contract **match** the vendored one | **no.** `schema.json`, `schema.ts` and `types.d.ts` all move. That is what adopting this base costs | +| `verify` on both identities | upstream clean at `082e6ea52186`; fork clean at `3786b840e1a4` **on** `082e6ea52186` — the merge-base assertion | + +**The measurement disagreed with the prediction, and the measurement wins.** +`packages/contracts/src/orchestration.ts` — rated **High** in `FORK.md`, and the file upstream +changed twice in exactly the unions we extend — **auto-merged clean**. What conflicted instead was +an upstream **test**, which `FORK.md` had already flagged as the half easiest to forget when +estimating the job. The risk table now carries both numbers. + +**Zero churn would also have passed**, reported as `NO_UPSTREAM_MOVEMENT`. It is not what happened; +the churn is real, and which of the three outcomes it was is stated rather than left as a bare zero. + +### Regeneration after a rebase is proved, not deferred + +**This was the gap, and it was worth closing.** Until 2026-08-31 the drill measured the generator's +*inputs* and said honestly that it had not run the generator. That proves the rebase **measures**; it +does not prove the contract still **regenerates** after one — and the second claim is the one that +matters on the day a new base is adopted, which is the worst moment to discover it does not hold. + +`generate.mjs` refuses any checkout whose `HEAD` is not `pin.commit`, so pointed at this repository +"regenerate from the rebased tree" means moving the real pin. The way around that is not to loosen +the guard — it is to satisfy it somewhere disposable: + +1. `git merge-tree --write-tree` writes the merged tree as an object and `git commit-tree` gives it + an identity, **inside the throwaway clone**. The sequential rebase stopped at commit 6, so there + is no rebased HEAD; the generator reads only the closure, and the closure merged clean. +2. A **scratch codegen root** is assembled beside it. `generate.mjs` resolves `pin.json`, its output + directory and its staging area from its own file location, so a copy of the tool under a scratch + directory reads a scratch pin naming the merged commit and writes to a scratch `generated/`. The + guard is satisfied honestly rather than bypassed: the artifacts really are reproducible from the + commit they name. +3. The generator runs, and its output is compared **byte for byte to the artifacts vendored in this + repository** — never to what the scratch run itself just wrote, which would be the tautology this + whole document is organised around refusing. + +**Result: the contract regenerates, and three shape artifacts move.** `schema.json`, `schema.ts` and +`types.d.ts` all differ. `ATTRIBUTION.md` and `source-hash.json` also differ and are listed +separately as `embedsCommitId`, because they name the commit they came from and would differ after +any rebase — `source-hash.json` carries real signal too, so it is named rather than filtered out. + +**A regenerated contract that differs is a result, not a failure** — the same argument as +`conflicts`. It is the answer to "what would adopting this base cost", measured rather than +predicted. + +**The generator needs Node 22 or newer** (it imports the closure's TypeScript directly); the drill +itself runs under 20. An interpreter that cannot run it reports `attempted: false` with +`NO_INTERPRETER`, never "the contract does not regenerate" — that would be a claim about the fork +made on the strength of a fact about this machine. `T3_CODEGEN_NODE` overrides. + +**The real `pin.json` was neither read nor written for this**, and the read-only order is re-checked +afterwards as on every run: upstream still at `082e6ea52186` and clean, fork unmoved and clean, +`pin.commit` unchanged. + +### What the criterion 9 wording asks for and what was run + +**Stated plainly so the reader rules rather than infers.** Criterion 9's words are "the fork rebases +onto a later upstream, the contract regenerates and passes `shape-check`". Two of those did not +happen, and neither is an oversight: + +| Asked | Run | +|---|---| +| the fork rebases onto a later upstream | it does not rebase **cleanly** — it stops at **commit 6**, and the whole surface is 3 files. Both totals are in the generated block above rather than typed here. The plan is explicit that `conflicts` is a result the drill exists to produce, not a failure | +| the contract regenerates | **run, and it does.** The generator completes against the merged tree in a second throwaway | +| ...and passes `shape-check` | **run, and it does not.** `schema.json`, `schema.ts` and `types.d.ts` move. A moved shape is the cost of adopting the base, not a failure of the drill | +| `verify` holds on both identities | run, holds — upstream clean at `082e6ea52186`, fork clean at `3786b840e1a4` on that base | +| upstream churn measured and non-zero | run, 104 commits, 5 closure-touching | + +The plan's phase 11 amendment settles this: **"Criterion 9 is met by the procedure completing and +reporting, not by adopting a new base."** Under that reading it is met, and the row above says so in +those terms rather than as a bare "met". Under the literal reading, one clause remains open — the +fork does not rebase *cleanly* — and no wording here should let a reader arrive at "met" without +seeing which of the two they are being handed. + +Two of the four clauses were closed after the fact, on 2026-08-31, when the architect asked whether +regeneration could be proved cheaply in the throwaway rather than deferred to the first real rebase. +It could. It took about an hour, and it changed the answer from "not run" to "runs, and three shape +artifacts move". + +### What the drill still does not do + +**It does not adopt the base, and that is the whole design.** The real `pin.json` is never advanced; +`verify-upstream` still expects the preserved clone to be at `082e6ea52186`, and every spec 146 and +spec 236 result tied to that commit stays re-runnable. Advancing the base is a decision taken when +there is a reason, never as a phase deliverable. + +**It does not resolve conflicts.** The merged tree it generates from carries conflict markers in the +3 files that conflict; none of them is in the closure, which is why the generator runs at all. If a +closure file ever conflicts, the generator fails and that failure is the reported result rather than +a silence. + +### The account of how this got here, kept because the defect recurred + +**The drill never regenerates the contract and never runs `shape-check`.** Its first draft's header +said the `ok` outcome meant "rebase clean, contract regenerated, shape-check held", and listed +`regenerate-failed` and `shape-check-failed` beside it; the code assigned neither, and the clean +branch called neither tool. Both review lanes found it. The contract was described as regenerated by +a comment rather than by a generator, which is the same shape as this project's other two +phase-review findings — a claim spelled the same way whether or not anything checked it. + +It is not an oversight that can be closed by calling the generator. `generate.mjs` refuses any +checkout whose `HEAD` is not `pin.commit`, and a rebased tree never satisfies that: its head is a +commit that did not exist before the rebase. Regenerating from one means **moving the pin**, which is +the adoption the drill exists in order not to perform — step 3 of `tools/t3-codegen/REFRESH.md`, +taken when a rebase is adopted for a reason. + +So the drill now says what it can and marks what it cannot: + +- the outcome vocabulary is `ok` | `conflicts` | `could-not-run`, and a test asserts the documented + set and the assignable set are the same set; +- `contractRegeneration` is on every result of a drill that **ran** — `ok` and `conflicts` — and + carries `attempted: false` with a reason whenever there was no tree to generate from. + `could-not-run` carries no measurement-shaped fields at all, deliberately: it means nothing was + learned, and a field on it is the first thing a reader would mistake for a finding; +- `contractClosure.sourceHash` hashes the closure **off the merged tree** and compares it to + `generated/source-hash.json` — the layer `generate.mjs` itself names as the load-bearing drift + detector, because the emitted schema is blind to constraints behind a `decodeTo` transform. + +That last one is the substantive answer, and it changed the picture: the closure merges without +conflict, so regeneration is not *blocked*, but **4 of 9 closure files come out with different +bytes**, so the regenerated contract would not be the one vendored. "Regenerable" and "unchanged" +were being read as one fact and are now two. + +**The ordering in that measurement is load-bearing**: the hash is taken while the merged tree is on +disk, before `merge --abort`. Taken after the abort the worktree is the fork again and the comparison +is the fork against itself — checked, and it reports `moved: []` on every run regardless of what +upstream did. + +## Criterion 6 — UNMET, and why + +**No iPad was available.** The architect asked twice and had no answer. This closes as unmet with a +stated reason, not as passed and not left open. + +- The runbook exists and is executable: `codev/resources/250-ipad-acceptance-runbook.md`, 16 + numbered steps, each verified against the fork rather than written from memory. +- **The Playwright suite is NOT a substitute, and it must not be recorded as one.** It drives the + same proxy and the same ceremony in a desktop browser, so it covers the approval path. What the + iPad closes is the **tailnet reach** and the **touch targets** — and nothing on the Mac tests + either of those. +- What it would take: a device on the tailnet, `pnpm dev:share` from the fork root, and about + fifteen minutes. + +## Regression runs, and what is red for reasons that are not ours + +| Tree | Command | Result | +|---|---|---| +| Codev | `npm test -- --exclude='**/e2e/**'` | **7396 + 180 passed, 58 skipped, 0 failed**, exit 0. Earlier runs of the same command reported 7377 (with the two timeouts diagnosed below) and 7387; the count grew by the phase 11 review-response and regeneration tests | +| Codev, e2e | `npx playwright test --config playwright.spec250.config.ts` at fork head `2f64a1b0ee2b` | **32 passed** in 2.3m, across all 4 spec-250 spec files. Re-run at the new head after the review round moved the pin — the previous run described `3786b840e1a4` and describing the shipped fork is the whole point | +| Fork, web | `apps/web && npx vp test run` | **2984 passed** | +| Fork, server | `apps/server && npx vp test run` (whole server suite) | **2873 passed, 8 skipped, 1 failed** — the `entrypoint.test.ts` symlink one | +| Fork, typecheck | `vp run --filter @t3tools/contracts --filter t3 --filter @t3tools/web typecheck` | clean | +| Fork, whole monorepo | `npx vp test run` from the fork root | **8949 passed, 1 failed, 24 suites failed to load** | + +**The Playwright row is a re-run, not a first run, and the distinction is worth stating.** The +criteria table cites phase 7-10 runs; phase 11 adds **no fork commit**, so `pin.commit` is still +phase 10's head and those runs were already runs at the final fork head. What changed afterwards is +codev-side only — tools, docs, and the frozen `apps/client` suite — none of which these specs load. +The re-run confirms that rather than correcting it, and it was worth 2.3 minutes to say so from a run +instead of from an argument. + +**It also had to be re-run once to get a real answer.** The first attempt reported `32 skipped` and +exited 0, because `T3_NODE` was unset and the fixture refuses to start the fork server without it. +That is the fixture behaving as phase 10 built it — a skip that carries its reason rather than a pass +— and it is why the row above says 32 passed and not "the suite is green". + +**The 24 load failures and the 1 failure are all environmental or pre-existing, and none is in a +package spec 250 touches.** Stated with the reason rather than waved at: + +- **22 × `apps/desktop/**`** — `Error: Electron failed to install correctly, please delete + node_modules/electron and try installing again`. The Electron binary is not installed in this + checkout. Spec 250 changes **0 files** under `apps/desktop` (measured: + `git diff ..HEAD -- apps/desktop .github` is empty). +- **`.github/scripts/thread-transfer-report.test.cjs`** — "No test suite found in file". A CommonJS + script the runner collects and cannot read. +- **`apps/web/src/terminal/ghostty/runtimeAbi.test.ts`** — needs a native artifact this checkout + does not build. +- **`spec-250-vendoring-identities > reports zero fork drift as a named zero`** — timed out at the + 5s default on the first phase-11 run, 2s standalone. **Not flaky: the budget was wrong.** + `classify-churn --fork-drift` re-emits the pinned closure once per closure-touching commit, and + that range grows as the fork does. Raised to 30s with the reason at the call site. +- **`session-manager.test.ts > stderr tail logging (integration)`** — has timed out under + full-suite load twice, in two sibling tests of the same block. Both spawn a real process, both + pass alone. Recorded, not skipped. +- **`apps/server/src/entrypoint.test.ts > matches through a symlinked entrypoint`** — the one real + test failure, and it is pre-existing: byte-identical to the base commit, and macOS resolves + `/var` to `/private/var`. Not skipped and not modified — editing an upstream test we did not + break is gratuitous divergence on a fork that has to rebase. + +## `apps/client`, the frozen fallback + +**Frozen: confirmed.** `git diff ..HEAD -- apps/client` is empty; its last commit +is spec 236's. Nothing from spec 250's phases 7-10 was backported. + +**Green: confirmed, and the suites are named** — "still green" should not itself be an unchecked +claim. + +| Suite | Command | Result | +|---|---|---| +| unit | `apps/client && npm test` (`vitest run`) | **16 files, 279 tests, 0 failed** | +| types | `apps/client && npm run check-types` (`tsc --noEmit`) | **clean** | +| e2e | `apps/client && npx playwright test` (3 specs, real servers) | **23 passed** | + +### It was red first, and the reason is worth keeping + +One of those 279 failed when phase 11 first looked. Spec 250's phase 5 regenerated the vendored +contract **from the fork**, our `codevGate` object landed ahead of the session object in the +generator's numbering, and the session-status enum moved from +`$defs.subscribeThreadOutput__Objects_6` to `_7`. `derive.test.ts` still read `_6`. + +Fixed — one character, plus a comment saying why the number moved and that a positional read of a +generated artifact will move again. + +**The freeze authorises this.** "Frozen means it keeps passing its tests and receives fixes, not +that new front-end features land in both places." A fallback whose suite is red is not a fallback: +the reason `apps/client` is kept is that if the t3code path fails there is still something that +works, and *works* is a claim its suite is the only evidence for. + +**The assertion message is why this cost a minute instead of an hour.** It said: *"the generated +contract no longer declares the session status enum where this test reads it. That is this test +needing a new path, not a mapping change."* Two very different problems — a stale read path and a +broken status mapping — look identical at the failure site, and the message named which one. +`expected undefined to be defined` alone would have sent a reader into `deriveRowStatus`. + +**The real gap is filed, not fixed here: [#265](https://github.com/pseudoseed/codev/issues/265).** +The root `npm test` filters to `@cluesmith/codev`, so nothing local runs `apps/client` at all — it +went red at phase 5 and nothing noticed until phase 11. CI would have caught it at PR time, which +makes it a near miss rather than a hole; "the frozen fallback's suite runs only in CI, and only once +a PR exists" is still too long a loop for the one package whose job is to still work. diff --git a/codev/resources/250-ipad-acceptance-runbook.md b/codev/resources/250-ipad-acceptance-runbook.md new file mode 100644 index 000000000..f56880338 --- /dev/null +++ b/codev/resources/250-ipad-acceptance-runbook.md @@ -0,0 +1,184 @@ +# Spec 250, criterion 6 — the iPad acceptance run + +**What this proves.** That the tree, the gate and the approval are reached from an iPad over the +tailnet, with no account and no cloud relay, and that a builder is driven to completion from it. + +**It closes one of two ways and never a third.** Either the run happens and criterion 6 is met, or +no iPad is available and it closes **UNMET with this runbook attached**. It does not close as +passed on a simulation, and it does not stay open. (Ruled 2026-08-31.) + +Everything below is a thing to type or a thing to tap. Where a step can fail in a way that looks +like success, the step says so. + +--- + +## Before the iPad is picked up + +These run on the Mac. Each ends in something to check, not just something to run. + +### 1. Start the fork's stack, shared on the tailnet + +**One command, from the FORK ROOT.** t3code has a first-class tailnet mode and it is better than +anything hand-rolled: `dev:share` starts the backend AND the web app, runs `tailscale serve` on the +web port, and sets `T3CODE_DEV_ALLOWED_ORIGINS` for the backend itself +(`scripts/dev-runner.ts:720-780`). + +```bash +cd /Users/chris/dev/t3code-codev +T3CODE_CODEV_AGENT_ORIGINS="local=http://127.0.0.1:4100" pnpm dev:share +``` + +It prints the tailnet URL — `https://..ts.net:5733`, **https**, because +`tailscale serve` terminates TLS. That is the URL the iPad opens. + +**Do NOT use `tools/t3-server/t3-server.mjs start-fork` for this run.** That harness is for the +tests: it starts on a throwaway data directory with empty data, which is exactly what makes the +phase 7-10 assertions about order meaningful and exactly wrong here. Criterion 6 says a builder is +driven to **completion**, so the run needs the real threads. + +`pnpm dev` uses the shared `~/.t3` here — the same home the installed T3 Code runs against — +because the fork is a plain clone rather than a linked git worktree, and `resolveWorktreeT3Home` +only diverts for linked worktrees (`packages/shared/src/devHome.ts:93-104`). Verified, not assumed: +`git rev-parse --git-dir` in the fork prints `.git`, a directory. **If that ever becomes a linked +worktree, this step needs `T3CODE_HOME` set explicitly or the iPad will show an empty app**, which +looks like a broken tailnet rather than a different database. + +**Check, from the Mac:** the printed URL answers. + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' https://..ts.net:5733 +``` + +`200`. If `tailscale serve` could not bind, `dev:share` **warns and carries on serving locally** — +by design, so a tailnet that is down does not stop the dev server. So a missing warning is part of +the check: read the startup output, do not just look for a running process. + +Fallback if `--share` is unavailable: `HOST=0.0.0.0 pnpm dev` binds every interface, and Vite +already allows `*.ts.net` hosts (`apps/web/vite.config.ts:152`). Plain http, and the backend then +needs `T3CODE_DEV_ALLOWED_HOSTS` only for a LAN IP or an ngrok name — note **`_HOSTS`**; +`T3CODE_DEV_ALLOWED_ORIGINS` is the backend's CORS list and is a different variable. + +### 2. Confirm the backend actually read the proxy's upstream + +`T3CODE_CODEV_AGENT_ORIGINS` is on step 1's command because **the backend reads it from its own +environment at start**. Setting it in another shell, or after the server is up, does nothing — and +the symptom is an empty picker on the pairing form, which reads as a broken feature rather than an +unconfigured one. + +Loopback (`127.0.0.1:4100`) is right: the proxy hop is Mac-to-Mac. **The iPad never talks to +`codev-agent`** — that is the whole point of the same-origin proxy, and it is what the phase 10 +Playwright suite asserts by recording every request the page issues. + +**Check:** with a t3code session in a desktop browser, open +`https://..ts.net:5733/api/codev/agent-targets`. It must answer +`{"targets":[{"id":"local"}], ...}`. + +### 3a. Mint the t3code pairing link — the iPad's way IN to t3code + +This is t3code's own pairing, and it is **not** the codev-agent credential. It gets the iPad into +the app; approving a gate needs the two tokens in 3b as well. + +```bash +t3 auth pairing create --base-url https://..ts.net:5733 +``` + +`--base-url` makes it print a ready `/pair#token=…` link, which is the whole point — it is one tap +on the iPad instead of a transcribed token (`apps/server/src/cli/auth.ts:74-76`). + +The `t3` CLI reads the same auth store the server does, so this works when both point at the same +base dir. Step 1 uses the shared `~/.t3`, and so does an installed `t3` with no location flags. If +your `t3` is pinned elsewhere, pass the same location flags `pnpm dev` is using, or fall back to +`POST /api/auth/pairing-token` against the running server with an existing bearer — the same route +the phase-10 fixture uses. + +### 3b. Mint the two codev-agent tokens + +They are different secrets with different purposes, and one does not substitute for the other. + +```bash +afx pair issue --purpose machine-credential --ttl-minutes 30 # step 11 on the iPad (pairing form) +afx pair issue --purpose client-session --ttl-minutes 30 # step 13 on the iPad (Session token) +``` + +`--purpose` is required and has no default, and a token minted for one ceremony is refused at the +other — so a wrong guess fails later and elsewhere. + +**The default TTL is 10 minutes and the maximum is 60.** Ten is tight for a token typed on an iPad +keyboard, which is why `--ttl-minutes 30` is written out here rather than left to the default. +Mint them when the iPad is in hand. + +Both are single-use. Write them somewhere you can retype from — they are transcribed by a human, +which is the whole reason the token field on the form is not masked. + +### 4. Have a real builder at a real gate + +Criterion 6 says "driven to completion", so this must be a live builder, not a fixture. + +```bash +porch status # confirm it is at a gate a human owns +``` + +Note the project id and the gate name. You will read both back on the iPad. + +--- + +## On the iPad + +Safari. No app, no account, no cloud relay. + +| # | Do this | You should see | If not | +|---|---|---|---| +| 5 | Open `https://..ts.net:5733` (the URL `dev:share` printed) | t3code's pairing screen, "Enter a pairing token to start a session" | A blank page means `dev:share` warned and served locally only — re-read step 1's output. A timeout means the iPad is not on the tailnet — check `tailscale status` on the Mac lists the iPad | +| 6 | Open the pair link from **step 3a** (t3code's own pairing), or paste its token into the form | The app loads with the sidebar | Landing back on the pairing form means the credential was already spent — mint another | +| 7 | Tap the sidebar toggle if the sidebar is off-canvas | **The tree: workspace → architect → its builders**, indented, with `Architect` captions | A flat list means `hasCodevHierarchy` is false — the threads carry no `role`, so this is not an iPad problem | +| 8 | Tap **Builders** in the sidebar | The grid, one pane per agent, each showing its porch phase and its last three messages | Panes reading "Phase needs a codev-agent credential" is expected here — you have not paired with the agent yet. That is step 9 | +| 9 | Open the gated builder's thread (tap its sidebar row — not a typed URL, so this also shows the row is reachable) | A rose **Waiting on you: ``** panel with the question and the choices | If the panel is absent the gate is not on the thread; check `porch status` again | +| 10 | Tap **Pair this browser** | The pairing form: a `codev-agent` picker, machine name, workspace path, token | An empty picker means step 2's check was skipped | +| 11 | Fill it in: agent `local`; machine `ipad`; workspace the **absolute path on the Mac** of the Codev workspace, e.g. `/Users/chris/dev/codev-1455` — it is the path `codev-agent` knows the workspace by, not the fork's path; token = the `machine-credential` token from **step 3b** | The form closes and the panel now says **as ipad on local** | A red line quoting an agent signal is the agent refusing — read the signal, it is the agent's own words | +| 12 | Go back to **Builders** | The panes now carry the real porch phase and real messages | Still "needs a credential" means the pairing did not store — private browsing blocks it, and the panel says so in those words | +| 13 | Paste the **`client-session`** token from step 3b into **Session token** | — | Reusing 3b's other token here is refused: a token minted for one ceremony is refused at the other | +| 14 | Tap **Approve ``** | "Approving…", then a progress line naming the server's phase and checks, then a green line with a timestamp, a machine and a session id | See the outcome table below | +| 15 | On the Mac: `porch status ` | The gate is `approved`, and `status.yaml` records the same session id and machine the iPad showed | A mismatch here is the finding this whole run exists to surface | +| 16 | Drive the builder to completion from the iPad | The builder proceeds past the gate | — | + +### Reading step 14's outcome — four answers, and two of them are not "no" + +| On screen | What it means | What to do | +|---|---|---| +| Green, with a timestamp / machine / session | Approved. Those three came from the server, not the page | Step 15 | +| **"Could not tell — …"** (informational, not red) | The server answered and the page could not read it. **The gate may well be approved** | Check `porch status`. Do **not** tap Approve again first | +| Amber, "The session ended" | The session idled out (30 minutes). Ordinary | Mint another `client-session` token and repeat from step 13 | +| Red, with a signal | A real refusal, in the agent's own words | Read the signal | + +--- + +## What to capture, for `codev/resources/250-acceptance-evidence.md` + +- The iPad model and iOS version, and that it was **Safari, no app**. +- `tailscale status` showing the iPad and the Mac on the tailnet, and that the URL used the tailnet + name rather than a LAN IP. +- Screenshots from the iPad at steps 7, 8, 9, 12 and 14. +- The `porch status ` output from step 15, beside the session id the iPad displayed. +- Anything that needed a retry, and why. A run that needed three attempts and says so is worth more + than a clean one that does not say what it skipped. + +## Teardown + +```bash +afx pair revoke ipad # withdraws the machine credential AND its approval capabilities +afx pair list # confirm: `ipad REVOKED`, and nothing else changed +``` + +`afx pair list` prints no secrets. Revoking one machine is per-machine by design — criterion 3 of +the phase-10 e2e asserts exactly that, so if another paired device stops working too, that is a +finding worth reporting rather than expected behaviour. + +## If no iPad appears + +Criterion 6 closes **UNMET**, and the review records: + +- that it was not run, and why — no device available, not a failure of the code; +- that this runbook exists and is executable; +- that the same path was exercised in a desktop browser by the phase 10 Playwright suite, over the + same proxy and the same ceremony, and that **this is not a substitute**: what the iPad closes is + the tailnet reach and the touch target sizes, and nothing on the Mac tests either. diff --git a/codev/resources/arch-critical.md b/codev/resources/arch-critical.md index 527f52178..8ee3b3347 100644 --- a/codev/resources/arch-critical.md +++ b/codev/resources/arch-critical.md @@ -7,7 +7,7 @@ and keeps the map in sync with arch.md's top-level sections. See codev/resources ## Critical facts (consult before deciding) - Framework files resolve at RUNTIME via the four-tier chain (.codev/ → codev/ → cache → skeleton); they are NOT copied into projects. Don't wire features to "scaffold copies it." -- Governance docs are two-tier (Spec 987): HOT arch-critical.md/lessons-critical.md are capped + always-injected; COLD arch.md/lessons-learned.md are reference. Route new facts/lessons by tier; never grow a hot file past its cap (demote to cold). +- t3code is the FRONT END (Spec 250), via a private fork (`pseudoseed/t3code@codev`, `pin.commit`); `/Users/chris/dev/t3code` is the read-only upstream clone pinned to `pin.upstreamBase` — never check it out, never `gh repo fork`. `apps/client` is the FROZEN fallback: keep it green, build new front-end work in the fork. Every fork commit obliges `tools/t3-codegen/REFRESH.md`. - Two trees: codev/ = our instance, codev-skeleton/ = the template shipped to adopters. Mirror every framework change in BOTH. - CLAUDE.md and AGENTS.md MUST stay byte-identical (same content, two tool ecosystems). - Porch is a pure planner: it emits task JSON, Claude Code executes. Never hand-edit status.yaml. diff --git a/codev/resources/arch.md b/codev/resources/arch.md index fec97186c..aea1b848f 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -2218,6 +2218,51 @@ The command vocabulary lives in `@cluesmith/codev-types` (`canvas-command.ts`) a Tower, the sdk and the canvas package each keep a local `satisfies`-bound copy of any runtime list because codev-types is type-only for all three. +### The t3code Fork (Spec 250) + +**t3code is the front end; Codev integrates with it.** Every change we make to t3code is a +private customization that does not go upstream to `pingdotgg/t3code`. Spec 250 replaced spec +146's "do not touch t3code" premise, so **`apps/client` in this repo is now the FROZEN +fallback** — it is kept, its own suite stays green, and nothing from spec 250's phases 7-10 is +backported into it. Extending `apps/client` for new front-end work is the mistake this entry +exists to prevent. + +**Two checkouts, two identities, never one.** `packages/types/src/t3/pin.json` carries both: + +| | Upstream | Fork | +|---|---|---| +| Repository | `pingdotgg/t3code` (public) | `pseudoseed/t3code` (**private**, `codev` branch) | +| Pin field | `pin.upstreamBase` | `pin.commit` | +| Env override | `T3CODE_ROOT` | `T3CODE_FORK_ROOT` | +| Written to by us | **never** — a `git fetch` is allowed, a checkout is not | yes, one commit per phase | + +The upstream clone must stay on `upstreamBase` because every spec 146 and 236 result reproduces +against it; moving it breaks no test and silently makes recorded evidence unreproducible. That +is why `tools/t3-server/t3-server.mjs`'s `acquire`, `start` and `status` are pinned to +`upstreamBase` — `acquire()` runs `git checkout --detach`, so a fork-pinned `acquire` would +write a fork sha into the read-only clone from an ordinary test run. + +**The private repository is a created repository, not a GitHub fork.** `gh repo fork` inherits +the source's visibility, so it cannot produce a private copy of a public repository; the repo +was created with `gh repo create --private` and the history pushed into it. Never `gh repo +fork` this. + +**`pin.contractSource` says which identity the vendored contract came from.** `"fork"` since +phase 5, which makes a fork HEAD ahead of `pin.commit` an *error* rather than the expected +state it was under `"upstream"`. Every fork commit therefore obliges the full refresh cycle in +`tools/t3-codegen/REFRESH.md`; `generate.mjs --check` is the gate. + +**Codev's server-side additions stay out of upstream's numbered migration registry.** +`apps/server/src/codev/schemaGuard.ts` applies the added columns additively and keeps a +watermark, so an interrupted migration leaves the database openable by the pre-fork server and +a later upstream migration number is never shadowed. + +Where the pieces live: `tools/t3-fork/` (`FORK.md`, `identities.mjs`, `rebase-drill.mjs`, +`criterion-8b.mjs`, and 33 patches recording the customization), `tools/t3-codegen/` +(`generate.mjs`, `REFRESH.md`), `packages/types/src/t3/` (`pin.json` and the generated +contract). + + ### Internal Dependencies - **Git**: Version control, worktrees for builder isolation - **Node.js**: Runtime for agent-farm TypeScript CLI @@ -2299,6 +2344,8 @@ Spec 987 split the two governance docs into a **hot/cold** two-tier model so dur Hot files are materialized into projects by `copyHotTierDefaults` (wired into init/adopt/update) and resolve from the skeleton at tier-4 until a project curates its own. The cold files are likewise bootstrapped on init/adopt/update by `copyColdTierDefaults`, which copies minimal placeholder starters from the skeleton's `templates/{arch,lessons-learned}.starter.md` into `codev/resources/{arch,lessons-learned}.md` (issue #1012) — distinct from the rich `templates/{arch,lessons-learned}.md` reference templates, which are a manual-`cp` opt-in and are never auto-copied. Both materializers are skip-existing, so a project's curated copy is never overwritten; the cold files are registered as protected user data in `templates.ts`. Producers **route** new facts/lessons by tier at review time (see the review prompts); MAINTAIN + the `update-arch-docs` skill police the hot caps, displacement (demote to cold when full), and cold-doc map accuracy. The cap is load-bearing: it is what keeps the hot tier cheap enough to inject everywhere. +**Demoted from the hot tier (Spec 250):** the one-line "governance docs are two-tier" fact itself. Its slot was needed for the t3code fork, and it is the one hot entry whose content is fully restated where it is needed anyway — in this section, and in the header comment of each hot file, which every producer editing one is already reading. The routing obligation is unchanged; only its always-injected one-liner is gone. + ## Troubleshooting See the [Quick Tracing Guide](#quick-tracing-guide) for debugging entry points. diff --git a/codev/resources/lessons-critical.md b/codev/resources/lessons-critical.md index 4f0a925e1..c7e5972ed 100644 --- a/codev/resources/lessons-critical.md +++ b/codev/resources/lessons-critical.md @@ -6,14 +6,14 @@ every porch phase prompt and into CLAUDE.md/AGENTS.md. CAP: <=10 lessons, <=12 m MAINTAIN polices the cap and keeps the map in sync with lessons-learned.md's sections. --> ## Critical lessons (consult before deciding) -- Trust the protocol — never skip CMAP/consultation; it catches security, design, and protocol issues solo review misses. +- Trust the protocol — never skip CMAP/consultation; it catches security, design, and protocol issues solo review misses. When stuck (2 failed hypotheses or ~30 min), reach for it rather than guessing again. - Check for existing work (PRs, git history) before building from scratch. - "It compiled" / "tests pass" is not "it works" — verify the real user path end-to-end before calling it done. - "I could not tell" must never be spelled the same way as "no". A truncation, an unreachable API, and a server too old to answer each need their own signal and must emit nothing else — a partial or empty answer reads as a complete, negative one. - Single source of truth beats distributed state — consolidate duplicates rather than syncing them. - After any rename or framework change, grep the whole repo across BOTH codev/ and codev-skeleton/ before claiming "all fixed." -- When stuck (2 failed hypotheses or ~30 min), get an outside model's perspective and build a minimal repro — captured raw data beats guessing. -- A test that constructs the collaborator itself proves the collaborator works, never that production constructs it — assert the wiring against the production source too. +- A test that cannot fail is not a test — revert the fix and confirm the test fails before trusting it. Distinct from the "could not tell" rule above: that one is about a check reporting honestly, this is about whether it can report at all. +- A test that supplies the boundary itself cannot tell you the boundary exists — constructing the collaborator, calling under the wrapper, or hand-building the intermediate state each substitutes the thing whose absence is the risk. Test the seam, not the two ends. - "Who calls this in production?" grep before changing a long-lived API — vestigial code survives. - Verify reviewer/plan claims against the actual file before acting — summaries are evidence, not ground truth. diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index e8f50b8f4..df54ea9aa 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -315,6 +315,27 @@ just its inputs. ## Testing +- [From #250] **Naming a hazard in a spec does not prevent it. Only a test that can fail does.** + The spec named this exact failure — "pointed at our own fork head it compares our tree to itself + and reports no churn forever" — the plan carried the warning forward, and phase 1 shipped a + variant of it anyway: `classify-churn --fork-drift` measured `upstreamBase..pin.commit` instead + of `upstreamBase..HEAD`. Every review lane read the code with the warning in front of them and + none flagged it, because at the time the two commits were **equal** and every test had the right + answer for the wrong reason. + It only became reachable when a later ruling froze `pin.commit` while the checkout moved on, and + then a fork carrying real customization commits reported **zero drift** — "I could not tell" + spelled exactly like "nothing changed", by the one tool whose entire job is answering "what have + we changed?". Nothing errored; the answer was simply wrong and confident. + Two things generalize. First, a hazard written in prose is inert: it survives review because + reviewers check the code against the prose and the code matched. Second, **a test written while + two values are equal cannot tell you which one the code reads.** When a design says two things + are "the same for now and will diverge later", the assertion has to name which one is correct + *before* they diverge — or force them apart in a fixture — because after they diverge the bug is + already in production. The same shape appeared twice more in one phase: a range whose first + commit was never classified because `git log from..to` excludes `from`, and a fixture pair whose + git shas collided because identical content, message and author in the same second produce the + identical commit. + - [From #236] **To find every caller relying on an absent value, change the type and read the compiler errors.** A permissive `machine?: string` parameter on an identity check hid three call sites passing `undefined` — the very values that cannot be bound. Making it required enumerated @@ -522,6 +543,11 @@ so it survives review. Pin the constant to the highest migration block in a test - [From #13] **A CI tolerance guard that greps the whole log for a word can never fire.** `test.yml` tolerates a known vitest worker-teardown crash only when the output contains no "failed" — but the runner echoes the guard's own script into the log, and ordinary test names (`clear-failed`, "reports a failed Tower send") match too. The escape hatch had never once been reachable, so every worker crash was a hard failure. Scope such a check to the summary line it means, not to the whole transcript. - [From #241] **A test that builds its own collaborator cannot see that production never builds one.** Spec 146 shipped a correct thread driver, a correct `TurnTracker` and a correct `ResumingSubscription` across several phases, all well tested — and no production code ever opened a subscription, so no turn could settle. Every test constructed the subscription itself and would have stayed green forever. The fix is a second, uglier kind of test that asserts the *wiring* by reading the production source (`spec-241-subscriber-is-wired.test.ts`). Brittle by nature, and worth it: name the property each assertion protects so a rename tells the next person what to re-establish rather than what to delete. - [From #241] **When a component is only reachable through a factory, test the factory's call site, not just the factory.** "Is `X` constructed with the argument that makes it work?" is a different question from "does `X` work when given that argument," and only the first one fails when someone quietly drops the argument. +- [From #250] **A test whose work grows with the repository will look flaky before it looks under-budgeted, and the two have opposite remedies.** `classify-churn --fork-drift` re-emits the whole pinned closure once per closure-touching commit in its range. The range was near-empty when the test was written and 6 commits later; the test timed out at the 5s default under full-suite load and passed standalone in 2s — exactly the signature of flakiness. It was not: the work is real, bounded by history, and rising. Skipping it would have removed coverage of the tool's named-zero contract to hide arithmetic that was working. Before calling a timeout flaky, ask whether the *amount of work* changed since the budget was set; if it did, raise the budget and record the reason at the call site so the next person does not re-derive it. +- [From #250] **A careful vocabulary for reporting failure is not the same as reaching the code that reports it.** `approval.ts` documented four outcomes, kept "unconfirmed" distinct from "refused" in five places, and refused to invent an approval record from the browser's clock — and none of it ran when `fetch` itself rejected, because `send` awaited bare and four of its five call sites had no `catch`. Eleven review rounds read the taxonomy approvingly and nobody asked the cruder question. Two habits follow. **Grep for the transport call, not the error type**: every `await fetch(...)`, `await client.x()`, every await on something that crosses a process boundary, and ask what the caller does when it *rejects* rather than when it returns an error. And **make it a value, not a throw** — a function returning `{reached:false}` in a union forces every call site to answer at compile time, where a `try` is something a sixth call site can simply forget. +- [From #250] **A reviewer with no history of the work sees what the incumbents stopped seeing.** Two lanes reviewed all eleven implementation phases and approved the PR; a third, which had seen only the plan and then the finished diff, produced both remaining blocking defects in code the other two had just cleared. Familiarity with how a file came to be is what makes its assumptions invisible. Rotate a fresh lane in at the END of a long project, not only at the start. +- [From #250] **A rebuttal is scoped to the tests the argument actually covers, and it is worth re-checking which those are.** "The test's value is that it drives the tool against its REAL committed inputs, so a fixture copy would test a copy" was true — of one test in a file of six. The other five worked by *damaging* an input, and a damaged input has no reason to be the committed one. Applied to the whole file, the argument also concealed a race nobody had looked for: a second test file read one of the mutated paths **in its module body**, so a parallel vitest worker collecting it mid-mutation would fail on corrupted data with nothing in its own output to explain why. When rebutting a finding about a file, check whether the reasoning covers every case in it. +- [From #250] **Screenshot and harness runs can poison the suite that follows them.** A run that starts a real server and writes artifacts left the next in-process suite failing for reasons unrelated to its own code (issue #263). Re-run a suspect suite alone before trusting a failure, and make artifact-writing opt-in behind an env var so the ordinary run never pays for it. ## UI/UX @@ -726,6 +752,18 @@ so it survives review. Pin the constant to the highest migration block in a test ## Debugging and Root Cause Analysis +- [Demoted from the hot tier, #250] **When stuck (2 failed hypotheses or ~30 min), get an outside + model's perspective and build a minimal repro — captured raw data beats guessing.** Still true; + demoted rather than deleted when the hot tier's slot was needed for "a test that cannot fail is + not a test". **The trigger itself stayed hot**, folded into the consultation lesson at the top of + `lessons-critical.md` — a threshold only works if it is always-on, because a stuck agent does not + go and read the cold file, which is the whole reason it was hot. What lives here is the fuller + guidance: the minimal repro, and captured raw data beating guessing. The displacement rationale was that this overlaps the neighbouring "get an outside + perspective" territory, while nothing in the tier covered whether a check is capable of failing + at all — which spec 250 violated five times in one day (two tests asserting nothing, an in-memory + simulation standing in for a kill test, a raw `ALTER TABLE` standing in for a migrator run, and + decider-only coverage of a discriminant the engine was deleting). + - [From #47] **The repair and the evidence are often the same action, so count occurrences or every recurrence looks like the first.** Forwarding a misrouted message fixes it and erases it; reconciling porch state by hand fixes it and erases it; a builder noticing it was handed the wrong spec and working the issue anyway saves the hour and erases the collision — two of three builders did exactly that and it read as zero occurrences until the third did not notice. Nothing is left behind that a later reader could find. Write the occurrence down at the moment you repair it, and note that a bug you keep quietly working around has a recurrence count of zero by construction. - [From #12] **Killing a process is not the same as unblocking the caller, and a zero exit is not the same as success.** A shell timeout helper wrapped `tea api` in `$(...)`, killed it on schedule, printed its timeout message on time — and the command substitution stayed blocked for minutes, because a grandchild still held the write end of the pipe. Give the wrapped command a temp file instead of the caller's pipe, and redirect the watchdog's own stdout to `/dev/null` for the same reason. The second trap is subtler: classifying "we killed it" from the exit status (143/137) looks equivalent to recording it and is not — POSIX defines operand-less `wait` as *always* returning zero, so a wrapper script killed by SIGTERM reports success with an empty body, and the caller then misdiagnoses the empty response as a different failure entirely. Have the watchdog record that it fired; infer nothing. diff --git a/codev/reviews/250-t3code-front-end-customization.md b/codev/reviews/250-t3code-front-end-customization.md new file mode 100644 index 000000000..5352472ce --- /dev/null +++ b/codev/reviews/250-t3code-front-end-customization.md @@ -0,0 +1,1982 @@ +# Review — Spec 250: t3code is the front end + +## Summary + +t3code became Codev's front end through a private fork (`pseudoseed/t3code@codev`), across **11 +plan phases** landed as **106 `[Spec 250]` commits** on one branch, the remainder of its ~170 being +porch bookkeeping, across ~131 files and ~40.6k insertions. The exact figures move with every +commit to the branch, including the ones this review round added; `gh pr view 266 --json +commits,changedFiles` is the live count, and this paragraph is not it. +The fork gained a thread hierarchy (`parentThreadId` + `role`), a porch gate block with a +server-allocated revision, nested Workspace > Architect > Builders rendering in t3code's own +sidebar, a builder tile grid, and gate approval driven from t3code over a same-origin proxy. +**10 of the spec's 11 numbered success criteria are met** (criterion 9 under the plan's amended +reading). Counting the two sub-criteria the spec adds, 5b and 8b, that is **12 of 13**. The one +that is not met is criterion 6, the iPad run: it closes **UNMET** with a written runbook, because +no device was reachable. + +Every number in this review is regenerated, not typed: `tools/t3-server/collect-spec-250-evidence.mjs` +rebuilds the measurement tables in `codev/resources/250-acceptance-evidence.md` and `--check` +exits 0 against the committed file. + +## Spec Compliance + +Full evidence, per criterion, in `codev/resources/250-acceptance-evidence.md`. Phase attribution +and the one-line evidence pointer below. + +**Two repositories, and the paths below cross between them.** Anything under +`packages/`, `tools/`, `codev/` is in **this** repository and travels with the PR. Anything under +`apps/server/` or `apps/web/` — `schemaGuard.test.ts`, `threadHierarchy.test.ts`, `agentProxy.ts`, +the `Sidebar` components — is in the **fork**, `pseudoseed/t3code@codev` at `2f64a1b0ee2b`, and is +not in this diff. That split was ruled at plan time: the product change is the fork's, and this PR +is the harness that vendors and drives it. `apps/client` is the exception — it *is* here, and it is +the frozen fallback. The `.spec.ts` Playwright files are here (`packages/codev/src/__tests__/e2e/`) +and run **against** the fork's app. + +- [x] **1.** Architect + 3 builders render as a tree in t3code's own web app (Phase 7) — `spec-250-hierarchy.spec.ts`, 9 Playwright tests against the live fork app. +- [x] **2.** Two architects render as two subtrees (Phase 7) — `spec-250-hierarchy.spec.ts:265`. +- [x] **3.** A gated builder shows the gate name and #128's structured question **from the gate block, not the title** (Phase 8) — `spec-250-gate.spec.ts`, 9 tests, one asserting no thread title anywhere contains a gate name. +- [x] **4.** The gate is approved from t3code and porch records the approving session id, machine and timestamp in `status.yaml` over `codev-agent`'s capability path (Phases 4, 6, 10) — `spec-250-t3code-approval.e2e.test.ts` through the real fork server's proxy into a real `status.yaml`, plus `spec-250-approval.spec.ts` from a real browser. +- [x] **5.** Six builders at 1440x900, panes ≥340x240 CSS px, body text ≥13px, measured against t3code's chrome (Phase 9) — `spec-250-tiling.spec.ts:136`, measured from the browser's own geometry rather than the component's attribute. +- [x] **5b.** Seven panes at 1920 tile **4x2, not 3x3** (Phase 9) — `spec-250-tiling.spec.ts:243`. +- [ ] **6. UNMET.** Reached from an iPad over the tailnet, no account, no relay, driving a builder to completion. **No run — no device on the tailnet.** Closed UNMET rather than as passed or left open, with the procedure at `codev/resources/250-ipad-acceptance-runbook.md`; every step that does not need the device was verified. See "Criterion 6 — UNMET, and why" in the evidence document. +- [x] **7.** A `role: null` thread created by t3code's own UI appears where it always did and nothing in the new tree claims it (Phase 7) — `spec-250-hierarchy.spec.ts:291`. +- [x] **8.** An existing database opens against the customized server, added columns read as "not recorded", and a projection rebuilt over a pre-fork event log decodes every historical `ThreadCreatedPayload` (Phases 2, 3) — `apps/server/src/codev/schemaGuard.test.ts` and the fork's projector tests. +- [x] **8b.** A migration interrupted partway leaves the database openable by the **pre-fork** server, tested by killing the server mid-migration (Phase 2) — `tools/t3-fork/criterion-8b.mjs`, evidence at `codev/research/250-criterion-8b-evidence.json`, `passed: true`. +- [x] **9. Met under the plan's amended reading**, and the difference is stated rather than smoothed over (Phase 11). Run literally, the fork does **not** rebase cleanly: a sequential rebase stops at **commit 6** on `apps/server/src/server.test.ts`, and the whole conflict surface is **3 files of the 35 we modify**. (The total it is 6 of is generated, not typed: it rises with every fork commit, and it was written here as "42" until the review round's own fork commit made that 43.) The contract **does** regenerate from the rebased tree, and `shape-check` against it **moves 3 artifacts** (`schema.json`, `schema.ts`, `types.d.ts`) — the cost of adopting that base, measured rather than predicted. `verify` holds on both identities; upstream churn is 104 commits, 5 touching the pinned closure. All four clauses are tabulated against what was run in "What the criterion 9 wording asks for and what was run". +- [x] **10.** An approved gate cannot be re-displayed by a later write carrying a lower revision (Phases 4, 6) — the revision high-water-mark tests, and the live delivery in phase 6. +- [x] **11.** Hierarchy integrity refused by the server **at write time**, never rendered in a fallback (Phase 3) — `apps/server/src/codev/threadHierarchy.test.ts` plus live wire evidence at `codev/research/250-hierarchy-wire-evidence.json`, `passed: true`. + +The spec's open question — **does `apps/client` survive?** — was ruled by the architect during the +project: it is **kept as the fallback and frozen.** Nothing from phases 7-10 is backported. The +freeze authorises fixes that keep its own suite green, and nothing more. + +## Deviations from Plan + +Per phase, what changed and why. + +**Plan-wide, decided at review round 1.** Migration 900 was abandoned for +`apps/server/src/codev/schemaGuard.ts`: a numbered migration at 900 would have silently disabled +every future upstream migration below it, which is a rebase hazard that outlives the spec. + +**Phase 2.** Criterion 8b moved from simulated to exercised. The plan's version constructed the +half-applied state by hand; that substitutes the thing whose absence is the risk. It became a +real child process killed mid-migration (`tools/t3-fork/criterion-8b.mjs`). + +**Phase 4.** `CODEV_GATE_SCOPE_REQUIRED` was dropped from the refusal union — the transport +refuses first, so the reason was unreachable and a declared-but-unconstructable discriminant is a +lie in the type. Gate-writer provisioning became non-fatal and idempotent **by rotation, not +lookup**, because a lookup-keyed provision cannot be idempotent across a credential rotation. + +**Phase 7.** Added `parent-elsewhere` to the sidebar's unattributed group, which the plan did not +call for. A parent archived after the fact orphans its children, and dropping them silently is a +second correct-looking answer. + +**Phase 8.** The plan asked for a test of "a choice with no consequence". That state is +unrepresentable in the contract, so it is kept as a refusal test instead of deleted. + +**Phase 9.** **Criterion 4b was added at the architect's direction** — "four columns fit", keyed +on **width alone, never on builder count**. Spec 146's wording is unchanged; this is an addition +to what phase 9 asserts, not a reinterpretation of an existing criterion. + +**Phase 11 — the largest deviation, and it changed a success criterion's execution.** The plan's +phase-11 file list says `upstreamBase` and `commit` are "advanced by the drill". **They are not.** +Amended 2026-08-31 at the architect's direction: the drill runs in a throwaway clone and +`pin.json` never moves (`preserved.pinCommitUnchanged: true`). The reason is that the moment +`pin.json` names a new base, `verify-upstream` expects the preserved clone to *be* there, and +every spec 146 and 236 result tied to `082e6ea52186` stops being re-runnable. Advancing the base +is a decision taken when there is a reason — a security fix, a feature we need — never as a phase +deliverable. The spec's criterion 9 carries the amendment inline. + +Also in phase 11: `apps/client` was found red and had been since phase 5. Fixed under the freeze, +which authorises exactly that. + +## Consultation Feedback + +Lanes: **Claude** and **opencode** on every implementation phase, plus **codex** on the plan +round and on the PR's second round — where it produced both of the project's last two blocking +findings, in code two other lanes had just approved. The **Gemini/agy lane produced no output for this project** and is absent from every +round rather than recorded as an approval. No `CONSULT_ERROR` was raised in any round. + +Across **23 rounds** — 20 on implementation phases, 1 on the plan, 2 on the PR. Full per-round responses are +committed under +`codev/projects/250-t3code-is-the-front-end-privat/` as `*-rebuttals.md`. The **45 raw lane outputs +are `.txt` and gitignored** (`.gitignore:69`, `codev/projects/*/*.txt`) — they sit in the builder +worktree and do not travel with the PR, so the rebuttals are the durable record and the verdicts +below were transcribed from the raw files while they were still on disk. What follows is the +disposition of each round. + +**Almost nothing was rebutted, and one rebuttal did not survive.** Across the 23 rounds, four +items were answered rather than changed — one brittleness note in phase 4, one deliberate deferral +in phase 9, and the drill's regeneration in phase 11 (deferred twice with reasons, then closed +after the last round). The fourth, the evidence collector's file mutation, was **rebutted in phase +11 and accepted in the review round**: the argument was sound for one of the six tests and had been +applied to all six. Every other finding was accepted and fixed; six rebuttals say in as many words +that no finding in their round was a false positive. + +### Plan Phase (Round 1) + +All three lanes returned **REQUEST_CHANGES**. Twenty findings, **all Addressed** — the plan was +rewritten before phase 1 began. + +#### Claude +- **Concern**: Migration 900 would silently disable every future upstream migration. → **Addressed**: migration abandoned for `schemaGuard.ts`. +- **Concern**: Phase 1 breaks `spec-146-t3-contract.test.ts:254`; phase 5 breaks `:231`. → **Addressed**: both call sites updated in the phase that breaks them. +- **Concern**: Seven `T3CODE_ROOT` readers, not the three the plan named. → **Addressed**: all seven enumerated and repointed. +- **Concern**: Criterion 8b would pass by construction. → **Addressed**: rewritten as a kill test (and moot after phase 2). +- **Concern**: Phase 10 understates both modules it ports; fork-only phases carry no artifact in this repo; no abandonment path; fork suite scope unbounded. → **Addressed**: each written into the plan. + +#### Codex +- **Concern**: Gate revision semantics not implementable as written. → **Addressed**: revision became server-allocated. +- **Concern**: `codev:gate-write` unenforceable at the referenced point. → **Addressed**: enforcement moved to the capability path. +- **Concern**: Phase 6's project map would be dead code. → **Addressed**: removed. +- **Concern**: Persistence work named too few modules; the proxy has no upstream-target trust boundary. → **Addressed**: the target is configured, not derived from the request. + +#### opencode +- **Concern**: `codev.gateWrite` is never registered on the wire; gate commands must stay out of the client command unions; phase 5 would not vendor the method. → **Addressed**: all three — and the third resurfaced as a real defect in phase 5 (below). +- **Concern**: `acquire` still keys off `pin.commit`. → **Addressed**: this became phase 1's headline finding. +- **Concern**: Gate-write credential path unnamed; leftover revision return path. → **Addressed**. + +**Two findings of my own, raised while verifying the lanes**: the CSP claim was **false** in both +my plan and the spec, and three phases planned tests with a tool the fork does not have. Both +corrected in the plan. + +### Phase 1 (Round 1 — Claude APPROVE, opencode REQUEST_CHANGES; Round 2 — both APPROVE) + +- **opencode**: `ready()` still runs full `verify()`. → **Addressed**. +- **opencode**: `verifyCheckout` treats a failed `git status` as clean. → **Addressed** — "I could not tell" was spelled the same as "clean". +- **Claude**: `FORK.md` overstates "nothing re-derives it"; a test heading says "the seventh readers" over six. → **Addressed**, both. +- **Claude (round 2)**: `--since` bypassed the ref-resolution guard; no direct test for the `NO_UPSTREAM_MOVEMENT` named zero. → **Addressed**, both. +- Items each lane flagged as *unverifiable from its session* are listed in the rebuttal rather than counted as findings. +- **N/A**: both lanes brushed against `pin.commit` moving to the fork head and neither asked for a change. The plan puts it at phase 5, so phases 2-4 run with the fork checkout ahead of `pin.commit` and bare `verify` reports `FORK_CHECKOUT_MISMATCH` in that window. Plan sequencing, not a phase 1 defect; flagged to the architect rather than resolved in-phase. + +### Phase 2 (Round 1 — Claude REQUEST_CHANGES, opencode COMMENT; Round 2 — both APPROVE) + +- **Both lanes**: "a newly introduced upstream migration still runs" never invoked the migrator. → **Addressed**, and it uncovered more than the finding: a whole class of tests here asserted against hand-built state. +- **Both lanes**: criterion 8b was simulated, not exercised. → **Addressed** — became a real kill test. +- **Claude**: `forkSkipReason` says "ahead" for any non-matching head. → **Addressed**. + +### Phase 3 (Round 1 — both REQUEST_CHANGES; Round 2 — both APPROVE) + +- **Blocking, both lanes**: the engine deleted every reason discriminant one layer above the tests, so the deliverable was destroyed where no test looked. → **Addressed**; the more useful half is *why it went unnoticed*, recorded in the phase log. +- **Non-blocking**: a test asserting its own literals; a test asserting its own input fixture; `commandInvariants.test.ts` had no Codev cases. → **Addressed**, all three. + +### Phase 4 (Round 1 — both REQUEST_CHANGES; Round 2 — Claude APPROVE, opencode REQUEST_CHANGES; Round 3 — both APPROVE) + +The longest round chain in the project, because one defect kept reappearing in different costumes. + +- **BLOCKING**: the engine deleted gate refusals — the same shape as phase 3. → **Addressed**. +- **Concern**: "could not tell" shared a spelling with "no"; every unexpected cause was relabelled as a missing thread. → **Addressed**. +- **Concern**: `CODEV_GATE_SCOPE_REQUIRED` declared and never constructed. → **Addressed** by dropping it from the union. +- **Concern**: two of my own tests asserted nothing. → **Addressed**. +- **The finding that mattered most**: no projector coverage — the decider tests could not see the projector at all. → **Addressed**. +- **Concern**: the OAuth token allowlist exclusion was unasserted; the single credential was never named. → **Addressed**. +- **Round 2, opencode**: the credential had no production caller. → **Addressed** — costume one again, one layer further out. +- **Round 2, opencode**: the map row was asserted, the enforcement was not. → **Addressed**. +- **Round 2**: source-string assertions are brittle to reformatting. → **Acknowledged, not changed** in round 2; **Addressed** in round 3 once a non-brittle form existed. +- **Round 3, Claude**: `OrchestrationRefusal` kept a second copy of the refusal list; a doc comment documented a reason that cannot arrive. → **Addressed**, both. + +### Phase 5 (Round 1 — Claude REQUEST_CHANGES, opencode COMMENT; Round 2 — both APPROVE) + +- **Blocking, Claude**: `packages/types/src/t3/generated/schema.ts:2` named `51b55d4899e4` as a `pingdotgg/t3code` commit. That commit exists only in `pseudoseed/t3code` — the shipping module claimed upstream provenance for a fork commit, while `ATTRIBUTION.md` and `types.d.ts` had already been corrected. → **Addressed, and fixed one level up**: the three headers were three separate emissions of one claim and the third was a hand-written string elsewhere in `generate.mjs`, so correcting it in place would have left the shape that produced the miss. There is now a single `PROVENANCE` constant every emitter reads. +- Also closed in this phase, from the plan round: `codev.gateWrite` would have been vendored as **nothing at all**, because `generate.mjs` iterates `pin.methods` rather than the contract. The test that let it through asserted the input; it was replaced with one that asserts the generated output. +- **Round 2, one non-blocking note**. → **Addressed**. + +### Phase 6 (Round 1 — Claude APPROVE, opencode REQUEST_CHANGES; Round 2 — both APPROVE) + +- **opencode, blocking**: the gate watch is not torn down on reconnect. → **Addressed**; it needed a server the harness could not start, which is the finding's second half. +- **Claude**: three non-blocking notes. → **Addressed**. +- **Round 2, Claude**: the wire-evidence guard can flake on a fresh clone. → **Addressed**. + +### Phase 7 (Round 1 — both APPROVE) + +- **Concern**: `data-codev-builder-count` was two derivations of one fact. → **Addressed**. +- **Concern**: the tree covers the Active section only, documented nowhere outside a code comment. → **Addressed** by documenting it, and phase 8 inherits the boundary. +- **Concern**: no `package.json` script for the spec-250 Playwright config; `props.projectTitle ?? props.codevRoleLabel ?` reads ambiguously. → **Addressed**. + +### Phase 8 (Round 1 — both APPROVE) + +No blocking concerns from either lane. The fork suite was confirmed green after the last three +commits. One item was raised **by the architect during the phase rather than by a lane** and is +recorded as such in the rebuttal. + +### Phase 9 (Round 1 — Claude APPROVE, opencode COMMENT) + +Seven findings, all **Addressed**: the grid had no in-app entry point (and the test was complicit +in not noticing); the width was measured two ways; orphans were dropped from the grid; the sidebar +is 256px, not 232; two things the DOM was asserting that were not true; `--codev-pane-body` set and +consumed nowhere; the route computed the same grouping twice. + +- One item **Rebutted**: "`BuilderPane` has no props for phase and messages, so phase 10 has to change the component." True, and intended — the architect ruled that pane content comes from `codev-agent` over the same-origin proxy in phase 10, with the fork's contract left unextended. Adding empty props in phase 9 would have been guessing the shape of data the phase cannot fetch. + +### Phase 10 (Round 1 — Claude APPROVE, opencode REQUEST_CHANGES; Round 2 — Claude COMMENT, opencode APPROVE) + +- **opencode, blocking**: the vitest e2e reported a **PASS on a run that never happened**. → **Addressed**, and it is the single most valuable finding of the project: a green suite that never executed is indistinguishable from a green suite that did, unless something asserts the run occurred. +- **Claude, non-blocking**: `UPSTREAM_TIMEOUT_MS` claimed more than the mechanism gives — an idle timeout does not bound a trickling upstream. → **Addressed** by correcting the claim in `agentProxy.ts` rather than the mechanism; the limitation is stated, not hidden. +- **Both lanes**: `data-codev-approval-state` was coarser than its own words. → **Addressed**. +- **Round 2, Claude**: the same-origin assertion was a **prefix match** (`url.startsWith(origin)`), and the agent host's ephemeral port can prefix-match the fixed web-app origin — `http://localhost:5733` is a prefix of `:57330`-`:57339`, ten ports inside macOS's ephemeral range, so about **0.06% of runs** would have counted a genuinely cross-origin request as same-origin and passed the phase's central security assertion anyway. → **Addressed**. A rare false pass is worse than a common one: 0.06% is exactly the rate at which nobody ever sees it fail. +- **Round 2, Claude, non-blocking**: `blob:` alongside `data:`. → **Addressed**. + +### Phase 11 (Round 1 — Claude REQUEST_CHANGES, opencode COMMENT; Round 2 — Claude APPROVE, opencode COMMENT) + +- **Claude, binding**: the drill's `ok` outcome **claimed** the contract regenerated and `shape-check` held, while the clean branch ran neither and both failure states were unreachable. → **Addressed**, but not in the round that raised it. **Both** rounds' rebuttals recorded it as *not changed*, with a stated reason — `generate.mjs` refuses any checkout whose `HEAD` is not `pin.commit`, so regenerating appeared to require moving the pin, which the phase-11 amendment forbids, and loosening the guard would have traded a real invariant for two outcome labels. It closed **after** iteration 2 (commit `4178aa4b5`), once the guard could be **satisfied rather than bypassed**: `git merge-tree` gives the merged tree an identity inside a throwaway clone, and a scratch copy of the codegen tool resolves a scratch `pin.json` naming it. The real `pin.json` is neither read nor written. The claim was fixed by making it true, not by narrowing it, and both new checks were verified capable of failing. +- **Claude**: the criterion 9 `shape-check` row described the current pin, not the rebase result. → **Addressed**. +- **Claude**: churn `104 / 5` was hand-typed. → **Addressed** — the whole measurement block is now generated by `collect-spec-250-evidence.mjs`, with `--check` in the suite. +- **Claude**: the regression run excluded `**/e2e/**`, so criteria 1, 2, 3, 5, 5b rested on phase 7-10 runs rather than a run at the final fork head. → **Addressed**: 32 Playwright tests re-run at `3786b840e1a4`. +- **Round 2, opencode**: my own tests would have failed a *correct* zero-movement drill. → **Addressed**. +- **Round 2**: a comment outlived the test it described by one commit; the churn classification was hand-typed prose; `contractRegeneration` was not in "every result". → **Addressed**, all three. +- **Round 2, Claude**: `spec-250-evidence-collector.test.ts` mutates two committed files and restores them in a `finally`. → **Rebutted at the time, and overturned in the review round** — see below. The rebuttal argued that the mutation is the only way to prove `--check` can fail and that a fixture copy would test a copy. That holds for **one** of the file's six tests and was applied to all six; it also concealed a cross-file race. Reasoning at the time in `250-phase_11-iter2-rebuttals.md`, and the correction in `250-review-iter1-rebuttals.md`. + +### Review Phase (Round 1 — Claude APPROVE, opencode COMMENT) + +The PR review. Neither lane raised a blocking finding; three items were accepted and one round of +fixes landed. Responses in `250-review-iter1-rebuttals.md`. + +**Before either lane could run, both refused**, and correctly: `gh pr diff 266` returns HTTP 406 +because GitHub caps the diff media type at 20,000 lines and this PR is 43,714. Both printed "a +reviewer cannot tell an empty diff from a failed fetch" — and both **exited 0** while printing it. +Filed as **#267**. Worked around with a `gh` shim serving `git diff origin/main...builder/spir-250`, +verified to produce the same 130 changed files the PR reports. + +#### Claude +- **Concern**: `spec-250-evidence-collector.test.ts` mutates committed tracked files and restores them in a `finally`; a killed run leaves a dirty tree plus a stray backup, and parallel workers on the same paths would race. → **Addressed**, reversing the phase 11 round 2 rebuttal in part. That rebuttal's point held for **one** of the six tests — the one asserting the committed numbers still match the runs — and not for the five that work by *damaging* an input. The race is real and I had not checked for it: `spec-250-vendoring-identities.test.ts` reads `250-criterion-8b-evidence.json` in its **module body**, so a worker collecting it during the mutation fails on corrupted data with nothing in its output to explain why. The five damage cases now run a **copy of the collector under a `mkdtempSync` root**, the same `import.meta.url` technique the rebase drill uses — no flag added to the tool, nothing tracked written. Verified capable of failing, which this one needed: three of them assert exit 3 and `MISSING_RUN` is also exit 3, so an incomplete fixture would have passed them for the wrong reason. Removing all five mutations gives **5 failed, 1 passed**. +- **Concern**: the PR body says 166 commits, the review says 105 — reconcile or label what each counts. → **Addressed**. Both were also stale, taken against a local `main` behind `origin/main`. The branch is **167 commits, 106 of them `[Spec 250]`**, and both places now say which they count. +- **Concern**: `status.yaml` `history` records 9 review rounds while lane files exist for roughly 20. → **Addressed** by filing **#268**, not by editing the file. The pattern is exact rather than merely a gap: **a round is recorded if and only if at least one lane did not approve.** Phases 7, 8 and 9 — the three where both lanes approved on round 1 — are absent entirely, and every terminal approving round is missing. So a phase reviewed cleanly is indistinguishable from one never reviewed, and `history` understates review effort selectively, biased toward the phases that went badly. +- **Concern**: the product change lives in the private fork and is not reviewable from this diff; branch freshness and a live test run were unverified (no shell). → **N/A / checked**: the fork boundary was ruled at plan time and is stated in the PR body; the branch and counts were checked here with `git log` and `gh pr view`. + +#### opencode +- **Concern**: criterion 6 is UNMET; criterion 9 is met only under the plan's amended reading; #264 is filed and unfixed on the approval path. → **N/A** — all three are already stated in the review, the evidence document and the PR body in those words. Recorded as independent confirmation, not as findings. + +### Review Phase (Round 2 — codex REQUEST_CHANGES, and both blocking findings were real) + +The architect ran a third lane the first round did not have. **Two blocking findings, both +accepted, both fixed in-phase.** Round 1's claude APPROVE and opencode COMMENT stand; neither lane +had looked at the failure paths. + +#### Codex +- **BLOCKING — pre-submit network failures escaped as rejected promises, on the approval path.** `send` in `approval.ts` did a bare `await fetchImpl(...)`, and **four of its five call sites had no `catch`**: opening the human session, issuing the capability, minting the nonce, and the synchronous fallback. Only the async submit was guarded. `GateApproval.tsx` had a `finally` and no `catch`, so a proxy disconnect during any of those four stopped the spinner and **said nothing at all** — no error, no unconfirmed state, no outcome. → **Addressed.** + + This is the defect class the project spent 11 phases killing, on the highest-stakes surface it has: a human believes they approved, the builder never advances, and nothing says which happened. It survived 11 rounds of review because every lane read the *outcome vocabulary*, which is unusually careful, and nobody read what happens when `fetch` itself rejects. A rich taxonomy of answers is not the same as answering. + + **The fix is a type, not a `try`.** `send` now returns `Sent` — `{reached: true} & Json | {reached: false, error}` — so a transport failure is a value. `reached: false` is not assignable to anything reading `.status`, so the compiler asks the question at all five call sites and a future one cannot inherit the bug by forgetting a `try`. + + **And the outcomes are not the same, which is the part that matters.** A failure *before* the approval is submitted means nothing was ever asked for, so the gate provably did not move: `AGENT_UNREACHABLE_HUMAN_SESSION`, `AGENT_UNREACHABLE_CAPABILITY`, `AGENT_UNREACHABLE_NONCE`, each definite, none `unconfirmed`. A failure *on* a submit means nobody knows: both the async route and the synchronous fallback report `GATE_APPROVAL_UNCONFIRMED`. Flattening those into one "network error" would have been the same defect in a tidier coat — telling someone to check a gate that cannot have changed teaches them that `unconfirmed` is ordinary noise, which is exactly how the rare real one gets ignored. The session step additionally warns that its single-use token may have been spent even though the reply was lost. + + `GateApproval.tsx` gains a `catch` as a backstop for defects rather than for the network, and it reports `unconfirmed` — an unexpected throw says nothing about whether the agent acted. + + **Six tests, one per outcome, all verified capable of failing.** Restoring the throw in `send`: 6 failed, 19 passed — and they fail *by rejection*, which is the bug itself. + +- **BLOCKING — the upstream response was buffered with no limit.** `agentProxy.ts` pushed every chunk into an array with nothing watching the total. → **Addressed** with `MAX_PROXIED_RESPONSE_BYTES` (1 MiB), a new `oversized` outcome, and an upstream abort. + + The same defect as the request-body bound found and fixed before review, on the return path — and the worse half. A request body comes from an authenticated caller; a response body comes from whatever the configured target is, so an operator misconfiguration or an agent that streams without end made the server buffer without end. + + `oversized` is **its own kind**, not `unreachable` and not a truncated `answered`. Forwarding the first megabyte as though it were the whole reply is a partial answer reading as a complete one, on the route that decides whether a gate was approved; calling it `unreachable` sends an operator to check whether a plainly-running host is running. + + **The first version of the fix had the bug it was fixing.** `destroy()` makes the stream emit `error` synchronously, and the `error` handler settles `unreachable` — so settling *after* the teardown reported a host that was answering as one that could not be reached. The truthful outcome lost a race to a vaguer one. `settle` is once-only, so claiming the outcome *before* tearing down is what makes it safe. Caught by the test, which is the only reason it is not in the merge. + + The test drives an upstream that sends past the bound and **never ends the response**, so a proxy that merely stopped reading would hang. Removing the bound: it fails by timing out at 120s, which is precisely what an unbounded buffer does. + +**What the pin move cost, and it is a finding of its own.** Fixing these needed a fork commit, and +a fork commit moves `pin.commit` — which invalidated **four** evidence runs at once. The collector +refused with `STALE_RUN` rather than publishing numbers about a fork that is no longer this one, so +`criterion-8b.mjs`, `spec-250-hierarchy.mjs`, `classify-churn --upstream-movement` and the rebase +drill all had to be re-run before the evidence would regenerate. That refusal is the mechanism +working: the alternative is an acceptance document describing a fork nobody is looking at. It also +means "every fork commit obliges the refresh cycle" is a larger obligation than regeneration alone, +which `REFRESH.md` now understates. + +The re-run moved one number that had been **hand-typed outside the generated block**: the drill +carries 43 commits now, not 42, so the prose "stops at commit 6 of 42" was wrong the moment the fork +moved. Both occurrences now name the stop point and point at the generated row for the total, rather +than restating it. `REFRESH.md` step 8 is new for the same reason — it said to regenerate and commit, +and said nothing about the four evidence runs, which is exactly how an acceptance document goes on +describing a fork that no longer exists. + +The Playwright suite was re-run for the same reason: its results describe the fork head they ran +against, and round 1 had already flagged that staleness once. **32 passed at `2f64a1b0ee2b`.** + +The first attempt at that re-run came back 4 failed, 28 did not run — and it was mine. I carried +`T3_HARNESS_PORT=3830` forward from the evidence runs, which need it because other sessions hold the +default ports, into the Playwright command, whose documented form omits it. The fixture's fork server +went to 3830 while the dev server on 5733 proxied to a port with nothing behind it, so the page served +200 with no threads in it and every locator timed out on a working server. **A variable that fixes one +tool can break the next one in the same shell.** + +- **N/A — criteria 6 and 9.** Codex is right that a runbook is not acceptance evidence. Both are the architect's rulings, both are disclosed as such in the PR body, the review and the evidence document, and neither is a change. Not chased. + +## Lessons Learned + +### What Went Well + +**The 3-way review found things no test could have.** The project's most expensive defects were +found by a lane and not by the suite: the engine deleting refusal discriminants one layer above +where the tests looked (phases 3 and 4), `codev.gateWrite` vendoring as nothing at all (phase 5), a +vitest e2e reporting a pass on a run that never happened (phase 10), and — after two other lanes had +approved the PR — the approval path having no answer at all for a dead network. Each was green +before the review. + +**A lane that had not seen the code before found what eleven rounds had not.** Codex reviewed only +the plan and then the finished PR. It produced the last two blocking findings of the project, in a +file eleven rounds of Claude and opencode had read approvingly. The reason is legible in hindsight: +those rounds were reading the *outcome vocabulary*, which is unusually careful, and a fresh reader +asked the cruder question of what happens when `fetch` rejects. **Rotating in a lane that carries no +history of the work is worth more at the end than a third opinion at the start.** + +**Evidence that regenerates cannot rot.** Every measurement in the acceptance document is produced +by `collect-spec-250-evidence.mjs` and checked by a test. Two rounds of "this number was typed" +findings stopped after the collector existed. + +**Falsifiability as a standing rule.** Every regression test in this project was verified by +reverting its mechanism and confirming it fails. That rule caught tests that could not fail in +phases 4, 9 and 11 — each of which had been written, read and reviewed while incapable of failing. + +**The screenshots ruled on things the tests approved.** Phase 7's tests passed on a tree the +screenshots showed was wrong; phase 9's tests passed on two defects the images made obvious. A +green Playwright run is not the deliverable. + +### Challenges Encountered + +**The same defect in five costumes.** A refusal reason declared in a type, deleted by an engine +layer, never constructed in production, asserted only in a decider test, and documented in a +comment that outlived it — one bug wearing five shapes across phases 3, 4 and 11. It cost three +review rounds in phase 4 alone. What eventually closed it was assertion **at the call site** +rather than at the module. + +**Two commits that were equal, and a test that could not tell which it read.** `pin.commit` and +`pin.upstreamBase` were deliberately equal until the fork diverged. `classify-churn --fork-drift` +read the wrong one, every test had the right answer for the wrong reason, and the spec had *named +this exact hazard in prose*. It only became reachable once a ruling froze `pin.commit` while the +checkout moved on — and then the tool whose entire job is "what have we changed?" reported zero. + +**A harness run poisoning the next suite run** (issue #263) made failures untrustworthy until the +rule "re-run a suspect suite alone before believing it" was adopted. + +**Two node versions in one project.** Fork tooling needs Node 22; the codev suite must run under +Node 20 or `better-sqlite3` fails 724 tests in a way that looks exactly like a regression. This +cost real time more than once and is now written into the fork docs. + +### What Would Be Done Differently + +**Write the evidence collector in phase 1, not phase 11.** Every hand-typed number in the +acceptance document became a review finding. A generator that emits the measurement block and a +`--check` test that fails when the committed file drifts would have removed three rounds of +findings across two phases. + +**Assert the seam before writing either end.** The phases that went cleanly (7, 8) are the ones +where the seam check came first. The phases that needed three rounds (4) are the ones where two +correct ends were built and the wiring between them was assumed. + +**Name which identity a test reads while the two values are still equal.** A test written when +`commit == upstreamBase` cannot tell you which one the code reads. Either force them apart in the +fixture, or assert the field name in the source — before they diverge, because after they diverge +the bug already shipped. + +### Methodology Improvements + +**Porch should carry a "run alone" retry for a suspect failure.** Issue #263's poisoning made +every full-suite failure ambiguous. A protocol-level convention — a failure in a full run is not a +finding until it reproduces alone — would have saved time in three phases. + +**A criterion that cannot be run needs a third status.** Criterion 6 is not met and not open; it +is **UNMET with a runbook**. The review template's checkbox has two states, and "we could not run +this and here is exactly how the next person does" is neither. It was written into the prose +instead, which works but relies on the reader noticing. + +**An amended criterion should be amended in the spec, in place.** Criterion 9's amendment is +inline in the spec with the original wording preserved above it, and the evidence document +tabulates all four clauses against what was actually run. That shape is worth making standard: a +plan-level amendment recorded only in a review is invisible to anyone reading the spec later. + +## Architecture Updates + +- **Routed: cold** — `codev/resources/arch.md`, new `### The t3code Fork (Spec 250)` under `## Integration Points`: the two checkouts and their two pin identities, why the upstream clone must never be checked out, why the private repo is a *created* repo rather than a `gh repo fork` (a fork inherits the source's visibility), what `pin.contractSource: "fork"` changes about `verify`, the `schemaGuard.ts` watermark, and where the tooling lives. Placed under an existing top-level section deliberately, so the hot file's cold-doc map stays at its 12-topic cap. +- **Routed: hot** — `codev/resources/arch-critical.md`: one fact — t3code is the front end via the private fork, `/Users/chris/dev/t3code` is read-only, never `gh repo fork`, **`apps/client` is the frozen fallback**, and every fork commit obliges `REFRESH.md`. This is hot rather than cold because the expensive mistake it prevents is one a builder makes *before* consulting anything: extending the frozen `apps/client`, or checking out the read-only upstream clone. +- **Demotion, to respect the 10-fact cap** — the "governance docs are two-tier (Spec 987)" one-liner moved out of the hot tier into `arch.md`'s `## Governance Docs (Hot/Cold Tiers)` section, which already stated it in full. It is the one hot entry whose content is restated in the header comment of each hot file, which any producer editing one is already reading. +- Caps after the change: **10 facts, 12 map topics, 32 lines** — unchanged, all within the stated limits. + +## Lessons Learned Updates + +- **Routed: hot** (during the project, phases 2 and 4) — `lessons-critical.md` gained "a test that cannot fail is not a test — revert the fix and confirm the test fails", and the collaborator-substitution lesson was widened to the rule the five costumes produced: "a test that supplies the boundary itself cannot tell you the boundary exists — test the seam, not the two ends." +- **Demotion, to respect the 10-lesson cap** (during the project) — "when stuck, get an outside model's perspective and build a minimal repro" moved to `lessons-learned.md`, with **the trigger itself kept hot**, folded into the consultation lesson. A threshold only works if it is always-on: a stuck agent does not go and read the cold file, which is the whole reason it was hot. +- **Routed: cold** — `lessons-learned.md`, Critical: naming a hazard in a spec does not prevent it; only a test that can fail does — with the `classify-churn` account, and the general rule that a test written while two values are equal cannot tell you which one the code reads. +- **Routed: cold** — `lessons-learned.md`, Testing: a test whose work grows with the repository looks flaky before it looks under-budgeted, and the two have opposite remedies; and harness/screenshot runs poisoning the suite that follows them (issue #263). +- **Routed: cold** — `lessons-learned.md`, Testing, from review round 2: a careful vocabulary for reporting failure is not the same as reaching the code that reports it — grep for the transport call rather than the error type, and make a transport failure a value rather than a throw so the compiler asks at every call site. +- **Routed: cold** — `lessons-learned.md`, 3-Way Reviews, from review round 2: a reviewer with no history of the work sees what the incumbents stopped seeing. Rotate a fresh lane in at the END of a long project, not only at the start. +- **Routed: cold** — `lessons-learned.md`, Testing, from review round 1: a rebuttal is scoped to the tests its argument actually covers. The phase 11 rebuttal was sound for one test in a file of six and had been applied to all six, and applying it that widely also concealed a cross-file race nobody had looked for. + +## Flaky Tests + + +`packages/codev/src/terminal/__tests__/session-manager.test.ts > stderr tail logging (integration)` +has timed out under full-suite load twice: `no stderr tail logged for file-based stderr` in phase 9, +and its sibling `logs session exit without stderr tail (stderr goes to file)` in phase 11. Both are +in the same block, both spawn a real process, and both pass alone. Recorded rather than skipped — +see the reasoning below, which applies to both. It spawns a real process, nothing in spec 250 goes near `src/terminal/`, and it +passed alone immediately afterwards and in the next full run (7370 passed, 0 failed). Recorded +rather than skipped: a test that passes on its own and once timed out under load is a timing +sensitivity, and annotating it as skipped would remove coverage to hide a slow machine. + + +### One timeout in phase 11 was NOT flaky, and calling it that would have been wrong + +`spec-250-vendoring-identities.test.ts > reports zero fork drift as a named zero` timed out at the +5s default in phase 11's full run, and passed standalone in 2s. The tempting conclusion is "flaky +under load". It is not. + +The test spawns `classify-churn --fork-drift`, which re-emits the whole pinned closure **once per +closure-touching commit in the range** — and that range grows every time the fork gains +customization. It was near-empty when the test was written and is 6 commits now. The work is real, +bounded by the fork's history, and rising; nothing about it is timing-sensitive. + +So the budget was wrong, not the test: a 5s default that silently became too small turns a passing +test into an intermittent one without anyone touching it. Raised to 30s with the reason recorded at +the call site, because the next person to see it fail should not have to re-derive this. + +The distinction matters because the two have opposite remedies. A flaky test is skipped or +stabilised; this one needed its budget corrected, and skipping it would have removed coverage of +`classify-churn`'s named-zero contract to hide arithmetic that is working correctly. + + +`apps/server/src/entrypoint.test.ts > matches through a symlinked entrypoint` fails in the fork. +Pre-existing and unrelated to spec 250: `git diff 082e6ea5 -- entrypoint.ts entrypoint.test.ts` is +empty, the module imports only `node:fs` and `node:url`, and macOS resolves `/var` to +`/private/var`. Not skipped and not modified — editing an upstream test we did not break is +gratuitous divergence on a fork that has to rebase. + +## Follow-up Items + +- **Criterion 6, the iPad run.** Not descoped — unrun. The runbook is at `codev/resources/250-ipad-acceptance-runbook.md`, and every step that does not need the device was verified. It needs a device on the tailnet and nothing else. +- **A page-level CSP for t3code.** Explicitly not done in phase 10, recorded as a follow-up in the plan. +- **The proxy's idle timeout does not bound a trickling upstream.** Stated at the mechanism in `agentProxy.ts` rather than fixed; a total-duration ceiling is the fix if it ever matters. +- **Issue #263** — a harness run poisons the next suite run. Filed, not fixed here. +- **Issue #264** — a spurious "gate approved, run `porch next`" message reaches a builder from its own Playwright fixture. Filed, and the architect ruled it out of scope for this spec. It fired twice in this worktree; both times `porch status` showed no pending gate. **Any gate-approval message should be checked against `porch status` before acting on it.** +- **Issue #265** — root `npm test` filters to `@cluesmith/codev`, so nothing local runs the frozen `apps/client` suite. That is how it stayed red from phase 5 to phase 11 without anyone noticing. +- **Issue #269** — nothing automated runs spec 250's acceptance suites. The 40 e2e tests need a running fork server and sit behind a separate Playwright config; the vendored contract's source is a private repository, so `generate.mjs --check` and `verify` cannot run in a CI job that has only this repo. The argument that a private fork is maintainable is only true while somebody is checking, and nothing checks automatically. Same shape as #265. +- **Issue #268** — porch records only *failing* consultation rounds in `status.yaml` `history`: 9 recorded against 20 that ran, with phases 7, 8 and 9 absent entirely because both lanes approved them on the first round. A phase reviewed cleanly reads exactly like a phase never reviewed. +- **Issue #267** — `consult --type pr` cannot review a PR over GitHub's 20,000-line diff cap, and exits 0 when it refuses. Hit on this PR: `gh pr diff 266` returns HTTP 406 at 43,714 diff lines, both lanes correctly refused to review a 0-byte diff, and both returned exit 0 — so a caller checking the exit status sees a successful consultation with no output. Filed with a `pr-diff` fallback to `git diff ...`, which has no cap and was verified to produce the same 130 changed files. +- **Issue #251** — folding the two t3code subscriptions per watched thread. Pre-existing, unrelated to this spec, noted because phase 6 touched the neighbourhood. +- **The architect has not ruled on the pane internals.** 12 screenshots at `docs/codev/spec-250/phase-10/` in the fork. The tests and measurements pass; what the panes *look like* is a human call and has not been made. + +--- + +## Phase-by-phase record + +Written incrementally as each phase landed, not reconstructed at the end. Kept because the +findings are the useful part: what was wrong, how it was found, and why the fix took the shape it +did. The sections above summarise; this is the working record. + +## Phase 1 — Two-identity vendoring harness + +### `acquire` wrote to the read-only clone + +`acquire()` runs `git checkout --detach` against `T3CODE_ROOT`, and both `tools/t3-server/smoke.mjs` +and `packages/t3-client/live/integration.mjs` call it. Left on `pin.commit`, it would have checked a +**fork** sha out into the **upstream** clone the moment the fork diverged — from an ordinary test +run, not a deliberate invocation. The upstream clone exists precisely to stay reproducible at +`upstreamBase`; every piece of spec 146 and 236 evidence verifies against it. + +Rewiring only `verify` would have left the one verb that *writes* still pointing at the fork. So +`acquire`, `start` and `status` are pinned to `upstreamBase`, and the test for it does not read the +source: it builds a throwaway repository with two commits, points `upstreamBase` at the earlier and +`commit` at the later, runs `acquire`, and asserts which sha the tree landed on. + +### `verifyCheckout` reported "clean" for a checkout it could not read + +Inherited from the spec 146 version, comment and all. The `git status` catch fell through to +`dirty = ''`, and an empty string is how "clean" is spelled — while the comment above it claimed +the case was reported as undetermined. It had been answering "fine" to "I could not look" for as +long as the file existed. + +Found by a review lane reading the new file, not by any test. The test that now covers it triggers +the condition for real (`chmod 000` on `.git/index` leaves `rev-parse HEAD` working and makes +`git status` exit 128) and refuses to pass vacuously if the platform ignores the mode. + +### Three exit codes, and the cases that were collapsed into the wrong one + +Adding a second identity multiplied the "could not determine" cases and several were initially +answered as `1`: + +| Case | Was | Is | +|---|---|---| +| Fork checkout absent | — | `3` `NO_FORK_CHECKOUT` | +| Fork HEAD unreadable | — | `3` `NO_FORK_HEAD` | +| `git status` failed | `0` **clean** | `3` `NO__STATUS` | +| Merge-base uncomputable | — | `3` `NO_FORK_MERGE_BASE` | +| Contract commit absent from the fork | `1` wrong commit | `3` `NO_FORK_ANCESTRY` | +| Fork does not descend from `upstreamBase` | — | `1` `FORK_BASE_MISMATCH` | + +The last row is the one worth keeping: "I could not compute a merge-base" and "the merge-base is +wrong" are different facts, and collapsing them would let a corrupt or mis-pointed checkout read as +a deliberate rebase. + +--- + +## Phase 2 — Thread hierarchy in the fork's contract and projection + +### The plan's wiring premise was wrong, and the wrong version would have passed its own test + +**This is the most important finding in the project so far.** + +The plan said to sequence the schema guard after `MigrationsLive` (`persistence/Migrations.ts:173`). +`MigrationsLive` is exported and **nothing in the tree builds it** — every reference is its own +definition or its own docstring. The real boot path is `persistence/Layers/Sqlite.ts`'s `setup`, +which calls `runMigrations()` directly and is what both `makeSqlitePersistenceLive` and +`SqlitePersistenceMemory` provide. + +A guard hung off `MigrationsLive` would therefore **never have run in production**. And it would +not have looked broken: a test that constructs `MigrationsLive` itself and asserts the columns +appear passes perfectly. The layer works. Nothing builds it. Those are different claims and only +one of them was being tested. + +This is #222's exact shape, and it is the failure mode that cost this program thirteen phases of +invisible plumbing. What caught it was reading the real boot path instead of trusting the plan's +premise — a grep for who actually constructs the thing, before wiring anything to it. + +The guard is called from `setup`, and the ordering assertion reads that production file rather than +any layer the test assembles: + +``` +const migrations = sqliteLayerSource.indexOf("yield* runMigrations()"); +const guard = sqliteLayerSource.indexOf("yield* codevSchemaGuardStep()"); +assert.ok(migrations < guard, ...); +``` + +A second assertion pins the guard inside `setup` specifically, because both persistence layers +provide `setup` — if it ever moves into only one of them, the other boots silently without the +columns. + +### Two spellings for the same two fields, and why a rebase must not "tidy" them + +`ThreadCreatedPayload` uses `Schema.NullOr(...).pipe(Schema.withDecodingDefault(Effect.succeed(null)))`. +`OrchestrationThread` and `OrchestrationThreadShell` use `Schema.optional(Schema.NullOr(...))`. + +**This asymmetry is deliberate and load-bearing. Do not unify it.** + +The payload keeps a decoding default because the event log is full of `thread.created` payloads +written before the fields existed, a projection rebuild replays every one of them, and the projector +reads `payload.role` unconditionally. Defaulting is what makes that read total; `optional` would +make it `| undefined` and push a branch into the hot path of every rebuild. + +The read models use `optional` because that is what `linkedPullRequest` — upstream's own newest +field, sitting one line above — does, and for the same stated reason: cached snapshots from older +servers still decode. Applying the strict form to them produced **32 errors across 11 upstream test +files**. On a fork that must be rebased for the lifetime of the project, that is a recurring cost +paid every rebase, in exchange for removing `undefined` from a read model the server never emits as +`undefined`: every server read path normalizes with `?? null`. + +Final upstream test churn for the whole phase: 5 fixture edits across 3 files. + +The trap for a future rebase is that the strict form *looks* more correct in isolation. It is more +correct in isolation. It is worse here, and the reason is not visible from the diff. + +### The ruling that froze `pin.commit` broke two phase-1 tools, silently + +`pin.commit` means "the vendored contract was generated from this commit" and only regeneration +moves it, so it stays at `upstreamBase` until phase 5. Both tools below were written when +`pin.commit` and the fork HEAD were the same commit, and both had the right answer for the wrong +reason until they diverged. + +1. **`classify-churn --fork-drift` measured `upstreamBase..pin.commit`.** After the freeze, a fork + carrying real customization commits reported **zero drift**. The tool whose entire job is + answering "what have we changed?" answered "nothing", confidently, with exit `0`. Now measures to + `HEAD`, which is correct on both sides of phase 5. +2. **The first commit in any range was reported `baseline` and never classified**, because + `git log from..to` excludes `from`. With a single fork commit the mode returned a placeholder + instead of a verdict. Seeded from the range start, guarded so a `--since` *date* still falls back + to `baseline` rather than guessing a commit. + +The generalizable half is in `lessons-learned.md`: a test written while two values are equal cannot +tell you which one the code reads. + +### The fork live suite skips for three phases, and says so + +`spec-146-t3-contract.test.ts`'s fork-hash suite compares generated artifacts against the fork +checkout — valid only while that checkout sits ON `pin.commit`. Through phases 2-4 it does not. + +Failing for three phases straight would train everyone to ignore a red suite, which is the failure +the ahead-vs-wrong distinction exists to prevent. It is gated on `FORK_AT_CONTRACT` and the suite +name carries which of three reasons it skipped for: + +``` +spec 250 [live: needs the fork checkout ON pin.commit — fork is at 1a414cee8409, +ahead of contract commit 082e6ea52186 (expected until phase 5 regenerates)] +``` + +A skip nobody can see the end of is how a suite quietly stops existing, so a test asserts the gate +is the contract commit rather than mere presence, and that it reopens by itself once phase 5 moves +the pin. + +### Two acceptance criteria were tested by substitution, and review caught both + +Both lanes named the same two, independently, and both were right. + +**"A newly introduced upstream migration still runs after the guard"** was tested with a raw +`ALTER TABLE pretend_upstream_column`. That proves SQLite accepts another column. It says nothing +about whether the watermark let the *migrator* execute one — which is the entire question migration +900 got wrong, and therefore the entire point of the test. The fix runs +`runMigrations({ toMigrationInclusive: 41 })` → guard → `runMigrations()` and asserts 42 actually +executed and its column exists, which is upstream's own idiom. + +The lesson is narrower than "test the real thing": the substitution was *adjacent* to the +mechanism and produced identical observable state. A column appeared either way. Only the path it +appeared by was in question, and that is exactly what the assertion had dropped. + +**Criterion 8b** — "the server is killed partway through applying the columns and the resulting +database still opens against the pre-fork server binary" — was an in-process simulation on an +in-memory database. No kill, no file, no pre-fork binary. Three of the criterion's four nouns were +missing and the remaining one still passed. + +It is now exercised, in `tools/t3-fork/criterion-8b.mjs`, recorded to +`codev/research/250-criterion-8b-evidence.json`: + +| Step | Result | +|---|---| +| Pinned `t3@0.0.36` creates and migrates a real database | opened and answered | +| Codev columns present before the run | none | +| Child SIGKILLed after the first `ALTER` | `SIGKILL`, `codev_role` alone on disk | +| **Pinned pre-fork server opens the half-applied file** | **opened and answered** | +| Fork's real guard resumes | added `codev_parent_thread_id`, found `codev_role` present | +| Pre-fork server opens the fully applied file | opened and answered | + +#### Why `start --keep-data` had to exist + +This is the part a future reader of a green 8b most needs, because a passing criterion carries no +trace of having once been unprovable. + +Writing the real test failed before it ran, on the harness rather than on the code. Criterion 8b +requires opening a database that a *previous* run left behind, and neither existing verb can: + +| Verb | What it does | Why it cannot host 8b | +|---|---|---| +| `start` | wipes the data dir, then starts | deletes the half-applied file the criterion is about | +| `restart` | stop-then-start, keeping data | refuses when nothing is running — and after a kill, nothing is | + +So the criterion had **no expressible form**, and had had none for as long as it had existed. That +is the finding, and it outranks the code: the in-memory simulation was not laziness, it was the +only thing the available tools could express. Whoever wrote it had a green check and no way to +learn it was green for free. + +Nothing reported the gap. There was no failing test, no error, no skip — the criterion sat in the +plan reading exactly like one that passes, and **it took writing the test to discover the test +could not be written**. That is a rung below "a check that answers when it could not observe": there +was no check, because nothing could host one, and an absence has no output to inspect. + +`start --keep-data` closes it. The verb exists solely so a criterion about opening an existing +database can be stated at all, which is why it is worth a line in the harness README and this +paragraph here rather than a one-word changelog entry. Filed as evidence on #199. + +--- + +## Phase 3 — Hierarchy integrity refused at write time + +### The engine deleted the entire deliverable, one layer above the tests + +Both review lanes found this independently, and it is the sharpest instance yet of the pattern this +project keeps producing. + +Phase 3's deliverable is six **reason discriminants**, so a caller can tell a retry ("no such +parent") from a caller bug ("wrong parent role"). `OrchestrationEngine` mapped every error the +decider raised except `OrchestrationCommandInvariantError` onto a generic invariant error reading: + +> `Failed to generate an event identifier.` + +For a hierarchy refusal that is not lossy, it is **false** — and it did not stop at the response. +The rejected-command receipt records `error.message`, and that receipt is replayed verbatim on any +redispatch of the same `commandId`. The wrong answer was the *permanent* answer. + +All fifteen decider tests were green throughout, because they call `decideOrchestrationCommand` +directly. **The decider is not a boundary anyone sees; the engine is.** A discriminant that does not +survive the wrapper does not exist, and no quantity of testing beneath the wrapper can report that. + +This is phase 2's `MigrationsLive` finding in a different costume: *testing the layer below the one +production uses*. There it was a layer nothing built; here it is a layer something wraps. Both pass +their own tests while production does something else, and in both cases the passing test is what +made the gap invisible. + +The regression test was **verified to discriminate** rather than assumed: with the mapping reverted, +3 of its 4 tests fail; restored, all 4 pass. On this project that check has stopped being optional. + +### Two of my own tests asserted nothing + +Both caught by review, and both worth recording because they are cheap to write by accident. + +**A Set of its own literals.** A test named "every refusal reports a distinct, actionable reason" +built `new Set([...six string literals])` and asserted `size === 6`. That proves six distinct +strings are six distinct strings. Every refusal could have collapsed onto a single discriminant and +it would have passed — while *claiming*, in its name, to guard exactly that. It now collects the +reasons six real dispatches return. + +**An assertion against its own input.** A test archived a parent and then asserted on +`model.threads` — the object it had just constructed, which the decider never mutates. It would +have passed whatever the decider emitted. It now asserts the output: one event, one aggregate, no +event mentioning the child. + +The common thread is that both tests read as if they were about the system, and both were about the +test. Neither could fail. + +### Orphaning by omission + +Archiving a parent is a single-aggregate event that says nothing about children. That is what makes +the orphan case work: nobody has to remember *not* to write a cascade. The test asserts the +omission directly — no emitted event mentions the child — rather than asserting a downstream state +that a cascade might also produce. + +Creating a builder under an *already archived* architect is accepted. The rule is about the edge's +shape, not the parent's lifecycle; refusing would mean archiving silently changes which commands +are legal, which is a second rule nobody wrote down. + +### Two things left deliberately, both recorded rather than fixed + +**`parent-not-architect` covers two of the plan's listed cases** — a builder parented to another +builder, and one parented to a `role: null` thread. They share a discriminant because they share a +*reason*: the parent is not an architect. The `detail` distinguishes them for a human. If phase 7 +ever needs to word them differently in the UI it will need two discriminants, and that is a cheap +change; splitting them now would have invented a distinction no caller acts on. + +**The ws/RPC hop is untested**, raised by review at the close of phase 3 and now a phase 6 +acceptance item rather than a note. The reasoning is this project's own history: it has been caught +twice testing below the layer production uses, and the boundary above the engine is the last hop +nothing has exercised. `porch-driver` is the first real client, so phase 6 is where a refusal +dispatched over the wire must still let a caller tell "no such parent" from "wrong parent role". A +discriminant that does not survive serialization does not exist. + +### A crashed run destroying passing evidence + +`criterion-8b.mjs` was documented as `> evidence.json`. A shell redirect truncates the target the +instant the process starts, so a transiently crashed run left an **empty** evidence file where a +passing one had been — and the suite then failed on a file that said nothing, rather than on the run +that broke. A good record was destroyed before anyone noticed. + +`--out ` now writes once, at the end, and only when the run passed. A failed run leaves the +previous record untouched and reports itself through its exit code and stdout. + +Worth generalizing: **a redirect is not a way to save a result, it is a way to destroy one early.** +Anything that records evidence a test later depends on should write on success, not on start. + +--- + +## Phase 4 — Porch gate block with a server-allocated revision + +### The mark has to outlive the thing it described + +Criterion 10 — clear an approved gate, deliver a lower revision, the gate does not reappear — is +carried by one design choice: `gateRevision` lives **on the thread, not inside the gate block**, and +the *clear* raises it as well as the set. + +Inside the block it would have vanished with the block, the stale write would have been the first +one at that revision, and an answered gate would be back in front of a human. The rule generalizes: +a monotonic guard has to outlive the state it guards, or clearing that state resets the guard. + +Two rules that looked contradictory in the plan resolve into one by making `revision` **optional on +the command**: absent means the server allocates, present means apply only if it exceeds the mark. +Equal is refused as well as lower, because two writers that computed the same number are colliding, +not agreeing. + +### The one defect, in five costumes — read this before wiring phase 6 + +#### The remedy, first, because the diagnosis is the easy half + +**Assert the call site, not the module.** + +Every one of the five below was caught by that single move, and every one of them would have been +prevented by it. Look at what the passing tests actually asserted: + +| The test said | The question it never asked | +|---|---| +| the provisioner writes a token | does the server ever run the provisioner? | +| the guard alters a column | does anything build the layer the guard hangs off? | +| the decider refuses, with a reason | does that reason survive the wrapper the caller talks to? | +| the projector applies an event | do the decider's tests use a projected model, or one they built? | +| the scope map has a row | does the transport read that row? | + +All green. All meaningless — not because the assertions were wrong, but because each asked whether +the code *works* and none asked whether production *reaches* it. Those are different questions and +only the second one was ever in doubt. + +So the remedy is mechanical and cheap: when the risk is "production may not reach this", the +assertion goes on the caller. Read `serverRuntimeStartup.ts` and require the provisioner to appear +in it. Read `Layers/Sqlite.ts` and require the guard to run after the migrator. Read `ws.ts` and +require the scope lookup to wrap every RPC. These read as crude tests and they are the only ones +that could have failed. + +#### And the diagnosis + +Five findings across phases 2, 3 and 4 are the same defect wearing different clothes. Every one of +them passed its own tests. Every one was found by review or by a compiler, never by the suite that +was supposed to cover it. + +| # | Costume | What was tested | What production did | +|---|---|---|---| +| 1 | **A layer nothing builds** | `MigrationsLive` constructed by the test, columns appear | `MigrationsLive` is exported and nothing builds it; the real path is `Layers/Sqlite.ts`'s `setup` | +| 2 | **A layer something wraps** | the decider's six discriminants, called directly | `OrchestrationEngine` rewrote them all as "Failed to generate an event identifier", then persisted it | +| 3 | **A decider tested without its engine** | gate revision rules, decider-only | `isRefusal` dropped the new refusal type; criterion 10 was false at the wire | +| 4 | **A read model every test hand-builds** | eleven decider tests, each building its own read model | a projector that dropped `gateRevision` would pass all of them while every write re-allocated revision 1 | +| 5 | **A module nothing calls** | the gate credential's scopes, path and write, all unit-tested | nothing in the server provisioned it; written one commit *after* this table | + +The single sentence they share: **a test that supplies the boundary itself cannot tell you the +boundary exists.** Constructing the layer, calling under the wrapper, hand-building the read model — +each substitutes the thing whose absence or misbehaviour is the actual risk. + +The rule that follows, and the one phase 6 needs: **when a value is produced in one layer and +consumed in another, test the seam, not the two ends.** That sentence is now the hot-tier lesson at +slot 8, which previously read *"a test that constructs the collaborator itself proves the +collaborator works, never that production constructs it"* — the same idea, one costume wide. The +collaborator case survives as an example clause. Phase 6 wires `porch-driver` across the +ws/RPC boundary — the last untested hop, and already an acceptance item — and it is the fifth place +this can happen. A `porch-driver` test that constructs its own transport would be costume five. + +Costume 3 is now closed by construction rather than by care: see below. + +**And then phase 4 produced costume five, one commit after writing this table.** The +`codev:gate-write` credential module named its scopes, named its on-disk path, tested the write — +and nothing in the server called any of it. Both review lanes found it. Its own tests were green and +every one of them was meaningless for the only question that mattered. + +That is worth keeping rather than quietly fixing, because it says something the table alone does +not: **knowing the pattern does not prevent it.** The table was written, committed, and the same +defect went in beside it within the hour. What caught it was review, and what stops it recurring is +the test that now asserts the *call site* — `serverRuntimeStartup.ts` imports the provisioner and +runs it as a named phase — rather than asserting the module works. + +### The same function, the third time + +`isRefusal` in `OrchestrationEngine` decides which errors reach a dispatcher intact. Phase 3 fixed +it once, for `CodevHierarchyInvalidError`. Phase 4 added a **third** refusal type and did not extend +it — so every gate refusal, including the stale write that *is* criterion 10, was rewritten as +"Failed to generate an event identifier", and criterion 10 was false at the wire while all eleven +decider tests stayed green. + +Both lanes found it independently. What generalizes is narrow and mechanical: **a predicate that +enumerates a category has to be extended whenever the category grows, and nothing in the type system +says so.** The union it feeds is structural; the predicate is a hand-written disjunction. Adding a +member to the union and forgetting the predicate compiles cleanly and silently drops the new member. +A type-level exhaustiveness check would have caught all three occurrences — so phase 4 added one +rather than filing a follow-up, on the reasoning that a follow-up issue is a promise to hit it a +fourth time. + +`dispatchErrorKind` now classifies **every** member of `OrchestrationDispatchError` in a switch whose +`default` assigns to `never`. `isRefusal` reads that classification instead of keeping its own list, +so there is one place to update. Adding a member without classifying it **does not compile**, and the +error names the forgotten type: + +``` +src/orchestration/Layers/OrchestrationEngine.ts(122,13): + error TS2322: Type 'ProbeUnclassifiedError' is not assignable to type 'never'. +``` + +Verified the way everything else on this project now is: a fourth member was added, the build was +confirmed to fail, and it was removed. At runtime an unrecognised error classifies as `internal` — +the safe direction, because a refusal misclassified as internal is a worse message, while an +internal error misclassified as a refusal is a lie about whose fault it was. + +This is worth more than the three fixes it replaces: it converts a recurring runtime falsehood into +a build error. + +### No test saw the projector, and the decider tests could not + +The best finding of phase 4, from the claude lane: nothing asserted that the read model applies +either gate event. + +Every decider test hand-builds its read model. So a projector that dropped `gateRevision` would pass +all eleven of them — while every write after the first re-allocated revision 1, and criterion 10 +became unenforceable. The mechanism's own test suite could not see the mechanism failing. + +This is the same family as the `MigrationsLive` and `isRefusal` findings, and worth stating as one +rule: **when a value is produced in one layer and consumed in another, a test that constructs the +intermediate state by hand tests neither.** The decider tests build the read model; the projector +builds it in production. Only a test that lets the projector build it can tell you the two agree. + +### Three claims, three tests, all verified to fail + +By phase 4 the "revert the fix and confirm the test fails" check had become routine, and it earned +its place three times in one phase: the engine's `isRefusal`, the projector's mark, and the wire's +decoding default. Each was verified by removing the mechanism and watching the specific test go red. + +Also caught this way: a test asserting the revision column rejects NULL ran its `UPDATE` against an +**empty table**. Zero rows touched, trivially successful. It was noticed only because the assertion +expected a refusal — written the other way round it would have passed forever. + +### A count hardcoded in a driver, failing for a reason unrelated to what it measures + +`criterion-8b.mjs` hardcoded a two-element Codev column list. Phase 4 added two more columns and the +driver failed — **while the criterion it exists to protect still held**. The pinned pre-fork server +opened both the half-applied and the fully-applied database exactly as before. + +That is a false negative on the thing the driver was built to guard, and it is worse than a plain +bug: the next person sees a red 8b, concludes the crash-safety property broke, and goes looking in +the wrong place. Now derived from what the guard itself reports, asserted as properties rather than +counts. + +### A test that could not fail, caught by running it + +The first version of "the revision column rejects NULL" ran `UPDATE projection_threads SET +codev_gate_revision = NULL` against an **empty table**. Zero rows touched, trivially successful, and +the assertion that it should have been refused failed — which is the only reason it was noticed. Had +it been written the other way round it would have passed forever while proving nothing. + +The fix is one line: insert a row first, so the constraint has something to refuse. The lesson is +that "assert the constraint, not the DDL" is not enough on its own — the assertion also needs +something for the constraint to act on. + +## Phase 5 — The vendored contract, regenerated from the fork + +### The undecidable verdict, and why it was neither a pass nor a break + +`classify-churn.mjs --fork-drift` returns three commits as `consumed-change-undecidable`. The +classifier sets `unknown` the moment a union's emitted JSON differs at all, additive or not, and +says so instead of guessing. The named input for this phase was one of them; running it against +the finished fork found three: + +| Commit | Method | Classifier | +|---|---|---| +| `1a414cee8409` (phase 2) | `orchestration.subscribeThread` | union shape changed; not decidable here | +| `e1b7f7b04af5` (phase 3) | `orchestration.dispatchCommand` | union shape changed; not decidable here | +| `3a1780bbf66f` (phase 4) | `orchestration.subscribeThread` | union shape changed; not decidable here | + +Deciding them means matching union members by their discriminant literal and comparing the matched +pairs, which is exactly the step the classifier declines to take. Done that way over the whole +range `upstreamBase..pin.commit`: + +``` +subscribeThread output /snapshot/thread/{role,parentThreadId,codevGate,gateRevision}: added + /event/payload/{role,parentThreadId}: added + /event: alternative added codev.gate-set + /event: alternative added codev.gate-cleared +dispatchCommand input /{role,parentThreadId}: added, neither required +``` + +Ten findings, and not one removal, narrowed type, newly-required property, lost enum member, or +tightened `additionalProperties`. Nine of the ten are non-breaking under the rules the classifier +already states. **The tenth is not, and it is the reason the phase exists.** + +On an *output*, a new union alternative is a shape the client must now handle. A client +shape-checking the `subscribeThread` stream against the pre-regeneration contract does not ignore a +`codev.gate-set` frame — it *rejects* it, because the frame matches no member of the union that +client knows. Phase 4 shipped the server side of gate writes into a repository whose vendored +contract could not decode the events they produce. + +So the verdict is: **non-breaking in every respect but one, and that one is breaking against the +upstream-generated contract and non-breaking against the regenerated one.** Regenerating is the +fix, not a formality that follows it. + +`spec-250-generated-contract.test.ts` holds both halves. The second half is the one that matters: +it rebuilds the pre-regeneration union by removing the two `codev.*` alternatives and asserts the +frame fails against it. Without that, "the contract accepts the frame" would be a claim about +nothing — the frame would have passed against a union that never rejected anything. + +### `codev.gateWrite` would have been vendored as nothing at all + +`generate.mjs` iterates `Object.entries(pin.methods)`, not the contract's RPC map. A method that +exists in the fork and is missing from `pin.methods` is not an error: it produces no schema, no +`methods.json` entry, and `checked.ts` then answers `unchecked` for every one of its payloads — +which is the "I had nothing to look with" signal working exactly as designed, arriving for a reason +nobody would have gone looking for. + +The precedent was already in the file. `vcs.*` are recorded by hand precisely because their method +strings live in the unvendored `rpc.ts`; `codev.gateWrite` is the same situation. What did not +transfer was the resolution: the non-`OrchestrationRpcSchemas` branch resolved schema names from +`git.ts` and nothing else, and `CodevGateWriteInput` lives in `orchestration.ts`. The branch now +takes the module from `spec.source`, and reverting that change makes generation fail with +`pin.json names CodevGateWriteInput for codev.gateWrite, but git.ts does not export it.` rather than +silently emitting nothing. + +`classify-churn.mjs` carried the same hardcoding and is fixed with it. Left alone it would have +reported `codev.gateWrite: ` at every commit — "the method is not in the contract" spelled +identically to "this tool looked in the wrong file". + +### The checker threw on the payload it was vendored to check + +The round-trip test for the new method did not fail an assertion. It raised +`UnsupportedKeywordError: shape-check does not implement JSON Schema keyword "minItems"`. + +Phase 4 bounded the gate's `choices` to one-to-five entries, which is the first schema in the +vendored closure to emit `minItems`/`maxItems`, and `shapeCheck` throws rather than passing on a +keyword it has not implemented — a refusal to report a match for something it did not check. So +`checked.ts` would have thrown at the call site on every gate-write payload, having been given a +schema it could not walk. + +Implementing the two keywords is a strengthening, not the relaxation the phase deliverable forbids: +nothing that passed before fails now, nothing that failed before passes, and the checker's stated +semantics — lower bound on branded ids, excess ignored to mirror the decoder — are untouched. Both +halves were verified by reverting: dropping the keywords from `SUPPORTED` makes the round-trip throw +again, and keeping them supported while deleting the two bound checks makes only the bounds test +fail. + +This is the fourth time on this project that a thing was wired and its call site was not exercised. +The test that caught it is the one that constructs the payload a caller would send and runs the +production checker over it, rather than asserting that the schema contains a `minItems` key. + +### The cold-start evidence had to be re-scoped, and its collector with it + +`spec-146-t3-contract.test.ts` asserted `evidence.pinnedCommit === pin.commit`. That held only while +the two identities were equal. This phase moves `pin.commit` onto the fork head, and the evidence +describes the **upstream** harness starting the **upstream** server from the read-only clone, so the +commit it should be checked against is `pin.upstreamBase`. + +Re-collecting it against the fork would have been the wrong fix — it changes what the evidence is +evidence *of* while every assertion stays green, and spec 146's criteria about the pinned harness +would quietly stop meaning what they said. + +Re-scoping only the test would have been half a fix. `smoke.mjs` still wrote `pinnedCommit: +pin.commit`, so the next collection would have recorded a fork sha as the provenance of an upstream +run. The field is therefore **renamed** — `pinnedCommit` to `upstreamCommit` — and reads +`pin.upstreamBase`. A rename rather than a re-point, so evidence written under the old meaning +cannot be read as though it were written under the new one; the test asserts the old key is absent, +which is what makes that true rather than merely intended. `collect-phase10-evidence.mjs` carried +the same expression and is fixed the same way. + +### A gate that had to reopen without being touched + +The fork-hash live suite is gated on `FORK_AT_CONTRACT` — fork HEAD equals `pin.commit`. It was +false by design for three phases and the suite skipped, naming its reason. Moving the pin makes it +true again with no edit to the gate. + +That is worth asserting because the failure is silent in the other direction: a regeneration that +put `pin.commit` somewhere the checkout is not would leave the suite skipping forever, reported as a +skip reason nobody reads, while `contractSource` claimed the contract was fork-sourced. The +assertion lives **outside** the gated block, because a gate cannot assert that it opened. + +### The flip, asserted against what actually ships + +Phase 1 built `FORK_AHEAD_OF_CONTRACT` exiting `0` under `contractSource: "upstream"` and `1` under +`"fork"`, with fixture repositories proving both. Those fixtures build their own pin, so they would +have kept passing if the shipped pin never flipped. The phase adds two assertions they cannot make: +one reading `pin.contractSource` from the file that ships, and one running the harness against the +**real** fork checkout with a pin whose `commit` is the real HEAD's parent — a genuine ancestor, +which makes the real checkout genuinely ahead, with the same run repeated under `"upstream"` so the +test cannot pass against a harness that simply exits 1 on every ahead-ness. + +### Two lanes, one finding, and the enumeration that let it through + +Both reviewers landed on the same line. `generated/schema.ts:2` still read `Source: + @ ` — a commit that exists nowhere in the repository the line names. +`ATTRIBUTION.md` and `types.d.ts` had been corrected; `schema.ts` had not, and it is the module +that ships. + +The three headers were three separate emissions of one claim, the third written as a standalone +string in a different part of `generate.mjs`. Fixing the third copy would have left the shape that +produced the miss, so there is one `PROVENANCE` constant now and every emitter reads it. + +The sharper half of the finding is the test. The attribution case named two files by hand, so the +artifact not on the list was the one that drifted — and the suggested remedy, extend the list to +three, would have caught this instance and not the next. The test is derived from the directory +instead: **every generated artifact naming the upstream repository must also name the fork and the +base**, read from `readdirSync`, with an assertion that it found artifacts at all so it cannot pass +vacuously. Verified by reverting the header and confirming only that test fails. + +### What can a human see or do now that they could not before + +Nothing yet. This is infrastructure. What changed is that `porch-driver` and `codev-agent` can now +send `role`, `parentThreadId` and `codev.gateWrite` against a vendored contract that knows them, and +a `codev.gate-set` frame arriving on the stream shape-checks instead of being rejected as +unrecognized. Phase 7 is still the first that renders. + +## Phase 6 — Hierarchy and gate state published by the Codev side + +### The finding, and it needed a server the harness could not start + +The plan's acceptance criterion for this phase is a live round trip: dispatch an +illegal hierarchy edge over a socket and assert the client can still tell "no such parent" from +"wrong parent role". The reason it is written that way is the record. Phase 2's schema guard was +wired to a layer nothing builds. Phase 3's six discriminants were rewritten by +`OrchestrationEngine` into a message that was not merely lossy but false. Both were green in every +test beneath the layer that broke them. + +**The harness could not run it.** `t3-server.mjs start` runs the published `t3@` +CLI against the upstream checkout — that is what every spec 146 measurement is about, and that +server has no `codev.*` anything. `parentThreadId` is not in its contract, so an illegal edge is +not illegal there; it is an unknown field the decoder strips. A "wire test" against it would have +passed and proved nothing. + +So the harness gained `start-fork`, which runs the fork's `apps/server/src/bin.ts` directly under +the same interpreter. There is no build step because there does not need to be one — the server +runs from source under Node's type stripping, the same way the codegen does — and adding a bundle +would put a build artifact between the source we changed and the server under test. It is a +separate verb rather than a flag on `start`, and it takes its own `T3_HARNESS_DIR` and +`T3_HARNESS_PORT`: the two bring up different servers from different checkouts, and a caller who +means one must not get the other by dropping a flag. + +**The first run failed on all four cases.** Every refusal arrived as +`OrchestrationDispatchCommandError` with the reason inside `message`, as English, behind a `cause` +holding a serialized `Error`. A client could not tell `parent-not-found` from +`parent-not-architect` without parsing a sentence. Phase 3 fixed the ENGINE deleting these; the ws +layer was flattening them one hop further out — the same shape, a third time, in the layer nothing +had yet crossed. + +### The fix, and the two things it taught + +`OrchestrationDispatchCommandError` gains an optional `refusal` carrying the refusing error's tag +and its machine-readable reason. `bootstrapThreadDisposition`, one field above it, is the +precedent: an optional machine-readable field so a client branches without reading prose. Optional +for the same reason — most dispatch errors are internal and have no reason to give, and +`refusal: null` on all of them would be a claim rather than an absence. + +`CodevHierarchyInvalidReason` **moved into the contract**, because it travels. A vocabulary that +reaches a client and is declared only in `apps/server` means every client keeps its own copy of six +string literals and checks it by hand against a file it does not import. Once it was in the +contract it could be vendored, and `porch-driver`'s copy is now checked against +`generated/schema.json` unconditionally rather than against a fork checkout the test had to skip +without. + +**Four wrapping sites, not one.** The test that asserts them found two the first fix missed — +including one that rebuilds an existing dispatch error in order to add a field, and would have +deleted the discriminant while adding it. That site is the more instructive of the two: it is not a +place that forgot to lift the reason, it is a place that copies three fields by name and therefore +drops every field nobody remembered to add. + +**The second run failed too, differently.** The live script read `domain.reason` — a true reading +of the old server and the wrong one for the new. That is worth recording because it is the shape of +a false negative: a test that was right about the world at the moment it was written, and whose +failure after the fix looks exactly like the fix not working. + +### Losing the question is better than losing the gate + +Codev bounds a gate request in BYTES (`GATE_REQUEST_LIMITS`); the fork bounds `CodevGate` in string +length, and tighter — a 1024-byte question porch accepts can exceed the fork's 500-character cap. +The fork refuses an oversize gate WHOLE, because a gate that partially applied would leave a human +looking at half a question. + +So the publisher narrows, and it narrows only the optional content: the question is dropped, the +choices capped at five, a second recommendation demoted, a terminal excerpt kept tail-first behind +a truncation marker. `gateName` and `requestedAt` always travel, because they are what say a human +is needed. Every drop is named to the caller — a silently shortened question reads as the whole one. + +The single case where the gate IS dropped is an unusable gate name. A name cannot be shortened +without changing which gate it names, and showing a human a gate they cannot match to their +protocol is worse than showing none. + +### The publisher invents no revision, and that is what makes a restart safe + +`codev.gate.set` takes an optional `revision` and this never sends one. A counter held in a +writer's memory resets when the writer restarts, and a reset counter makes every later write stale +— which renders as "no gate pending" exactly where a human is waiting. + +The corollary is that reconnect republishes CURRENT state rather than replaying history, and the +publisher gets that for free by living with the connection: a new socket builds a new +`GatePublisher`, which remembers nothing. Spec test scenario 4 — kill and restart mid-gate — is +therefore not a special case in the code at all, and the test models it by building a second watch +over the same workspace after changing `status.yaml` while nothing was running. + +**Only a confirmed write updates the publish memory.** A memory updated on failure would suppress +the retry, and for a gate waiting on a human "the next change to `status.yaml`" is forever. + +### A dropped cycle, spelled like nothing to do + +The first version of the publish cycle skipped a request while one was in flight and returned `[]`. +That is "I did nothing" spelled exactly like "there was nothing to do", and it is worse than it +sounds: the watcher fires on the same file change a caller is reacting to, so the dropped request +was reliably the caller's. Found by an integration test whose explicit `publishNow` silently did +nothing. Cycles are chained now, so every request runs, in order. + +### An unreadable status.yaml publishes nothing + +It does not clear. Clearing would spell "I could not read the file" like "no gate is pending", on +the one thread where a human may be waiting. + +### What can a human see or do now that they could not before + +Still nothing rendered — phase 7 is the first that renders. What is now true is that a spawned +builder lands on the fork with `role: "builder"` and its architect's thread id, a porch gate +reaching `pending` appears on the thread within one publish cycle, and a client that dispatches an +illegal edge gets back a discriminant it can branch on instead of a sentence it would have to parse. + +## Phase 7 — Workspace to architect to builder, in t3code's own sidebar + +### The seam check found two defects before a call site existed + +`hierarchy.ts` is a pure function and its tests build their own row type. Both of those are right — +that is what makes them tests of the grouping — and together they cannot tell you the module fits +anything the sidebar holds. Two assignments at the top of the test file are the whole check: + +```ts +const _sidebarRowsFit: (rows: readonly SidebarThreadSummary[]) => unknown = buildCodevHierarchy; +const _threadsFit: (rows: readonly Thread[]) => unknown = buildCodevHierarchy; +``` + +They failed, twice, on a module whose own suite was green: + +- it keyed on `threadId`, the **command** spelling, while both read models call it `id`; +- `role?: X` does not accept `undefined` under `exactOptionalPropertyTypes`, so the interface + described a shape no caller has until `| undefined` was written out. + +Neither is a runtime error and neither would have thrown. Both would have shipped as +`buildCodevHierarchy(threads)` quietly returning no hierarchy, which on screen reads as an empty +workspace rather than as a bug. This is the "assert the call site, not the module" rule working +**prospectively** rather than forensically: the previous five instances were found after the code +shipped, by running it; this one was found before a caller existed, by the compiler. + +### The section boundary, and the reason that was a lie + +t3code splits a project into Pinned / Active / Snoozed / Settled before any grouping runs, so the +tree is built over ONE of those lists. A builder whose architect the user has pinned is therefore +looking at a list its parent is not in — and the first draft answered `parent-missing`, three rows +below the architect the user can see. + +`buildCodevHierarchy` now takes `alsoVisible`, the rest of the sidebar, and answers +`parent-elsewhere`. Role still outranks section: a non-architect parent stays +`parent-not-architect` wherever it sits, because letting a section boundary change what a thread IS +would make the reason a fact about the sidebar rather than about the data. + +Reading that lookup was itself a bug, and its test caught it. `elsewhereRoleById.get(id) !== +undefined` cannot distinguish "not in another section" from "in another section, with no role" — +because a roleless thread's role *is* `undefined`, and the second of those is exactly the +`parent-not-architect` case the branch exists to name. It reads `has` now. Verified by reverting to +`get`: the test fails. + +### The render order is also the keyboard's order + +`orderedThreads` is not only a render order. Shift-range-select and jump-hint labels are both +assigned from it. A component that reordered rows into a tree while leaving that list alone would +draw every row in the right place and send the keyboard to the wrong ones — a defect no screenshot +shows and no component test that renders one list would see. So `buildCodevSidebarOrder` returns one +order, and the caller renders in it **and** derives `orderedThreads` from it. + +### Nothing changes for a project with no Codev roles + +`hasCodevHierarchy` is false there, and the renderer takes the loop it has always had: same rows, +same order, no wrappers, no headings, no divider. An empty tree's chrome would be new furniture in +every upstream user's sidebar for a feature they do not have. Asserted directly — the no-hierarchy +branch returns the input list unchanged, in input order. + +### Four reasons, four sentences + +The orphan group carries a sentence per row rather than one "could not be placed". A reader opens +that group to find out which of four things happened, and one string for four states is the shape of +"I could not tell" spelled like an answer — the same rule that produced `parent-elsewhere` in the +first place. + +### The e2e is a browser against the real stack, and it proved it can fail + +`packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts` drives the FORK's web app against a +server built from the fork's source, with threads created over the wire. Not a component harness: a +component test supplies the shells itself, which is the step whose absence is the risk. + +The orphan is made the way a real one is made — an architect archived after its builder exists — and +not by writing an illegal edge, because phase 3 refuses those at write time and a fixture that +produced one would be testing a state the server cannot reach. + +Verified to fail: with the render branch forced to the no-hierarchy path, all eight rows still +render and every hierarchy selector goes to zero. The assertions fail; they do not pass on a flat +list. + +### Screenshots are the deliverable, and there is no reference to compare them to + +Committed to the fork at `docs/codev/spec-250/phase-7/`, at 390, 1440x900 and 1920, two per width +(the page, and the sidebar list at its full height — the sidebar is its own scroll container, so a +900px window clips the orphan group). Measured at every width rather than eyeballed: no horizontal +overflow, every thread title at or above 13px, zero console errors after pairing. + +**There is no mockup or design reference for this tree.** The nesting is drawn in t3code's own +idiom — the same row cards, an indent and a hairline rail in the token the sidebar's other dividers +use, and a group heading in the shape of the existing Snoozed and Settled shelves. Nothing was +ported from `apps/client`. But "it matches the host app's conventions" is a claim about the +conventions, not a ruling on the appearance, and a green suite cannot make that ruling. Raised to +the architect with the screenshots rather than assumed. + +### Writing the screenshots had to become opt-in + +`t3-server.mjs start-fork` refuses a dirty fork checkout. A suite that wrote new PNG bytes into the +fork on every run therefore passes once and SKIPS forever, each run leaving behind the modification +that stops the next one — and the skip is correct behaviour, which is what makes it easy to miss. +Ordinary runs write into Playwright's output directory; `SPEC_250_WRITE_SCREENSHOTS=1` refreshes the +committed copies deliberately. + +### The screenshots found a criterion gap the tests could not + +The suite was green, every acceptance criterion had an assertion behind it, and the render was +still missing one of criterion 1's three levels. "Project, architect, that architect's builders" — +the tree had architect and builders, and the project was present only as a caption repeated on all +eight cards. Every test that could have caught it was written against the two levels that existed. + +That is the project's own lesson arriving on schedule: a green suite cannot detect design +infidelity, and here it could not detect a missing *requirement* either, because the tests and the +render were built from the same reading of the plan. The screenshot is what made the gap visible, +and it took a human looking at it. + +Two more came from the same review. Nothing said which row was an architect — it was carried by one +level of subtle indent plus test data that happened to be called "Architect beta" and "Builder alpha +one", and real threads are called `builder/spir-250`. And the orphan group was amber, which says +something is broken, on a state this project deliberately ruled legal. + +All three are fixed, and the assertions now exist for the first two: a project heading above the +tree, no row inside the tree repeating the project name, rows outside it still carrying it, the +architect row captioned and its builders not. The third is a colour, and the screenshot is its +evidence. + +### The tree covers the Active section only, and phase 8 inherits that + +Named here because it is an unstated narrowing of "the sidebar renders the tree", and the only other +place it is written down is a code comment. + +t3code splits a project into Pinned / Active / Snoozed / Settled before any grouping runs. The tree +is built over **Active**; the other three keep the flat rendering they have always had and reach the +grouping as `alsoVisible`, which is what lets a builder whose architect is pinned say +`parent-elsewhere` instead of `parent-missing`. That is the right scope for this phase — the split +is t3code's own and predates us — but a phase that assumes "every Codev thread is in the tree" will +be wrong for any thread the user has pinned, snoozed or settled. Phase 8's gate panel should read +gate state off the thread rather than off a position in the tree. + +### Live per-row status is t3code's, and it is not a gap + +Every row in the screenshots reads "now" with no working/turning indicator, which looks like spec +146 criterion 3 going unowned. It is not. `resolveSidebarThreadStatus` already returns +`approval` / `input` / `working` / `monitoring` / `failed` / `ready` from `session.status` and +`backgroundLiveness`, and the sidebar already renders it as a pill — spec 250 does not touch any of +it. The fixture's threads read "now" because they have never taken a turn: nothing is running on +them, so `ready` is the correct answer. The half spec 250 owes is **blocked on a named gate**, which +is phase 8's criterion 3. + +### What can a human see or do now that they could not before + +**This is the first phase with a non-empty answer.** Open t3code's sidebar and the threads Codev +created are a tree: the project as a heading, each architect below it captioned as one and above the builders +that name it, two architects side by side as two subtrees, threads Codev did not create in the flat +list they have always had, and a builder whose architect is gone named as orphaned with the reason +it could not be placed — instead of one flat list in which none of those things is distinguishable +from the others. + +## Phase 8 — A porch gate, rendered from the gate block + +### The gate has to be written by the credential that writes gates + +The e2e fixture could not seed a gate the way it seeds everything else. A bootstrap exchange asking +for `codev:gate-write` is refused with `invalid_scope` — phase 4's design holding, not a bug to work +around. Gate writes come from ONE credential, `codev-agent`, scoped to `orchestration:read` and +`codev:gate-write` and nothing else, provisioned by the server rather than derived from whatever +token a client happens to hold. + +So the fixture reads that credential from `/codev/gate-writer.token`, where the +fork's server writes it at start, and opens its own connection with it — exactly what +`thread-backend.ts` does in production, and for the reason that file already records: the seeding +socket carries `orchestration:operate`, and putting gate writes on it is precisely what phase 4 +gave the method its own scope to prevent. + +A fixture that had obtained the ability another way — widening the scope, writing the column — would +have been testing a path no writer uses. + +### The third state, and why it is a union rather than a nullable object + +`porch gate ` without `--request-file` is legitimate and common, so a gate can be pending with +no question and no choices. Rendering that as "no gate" hides a human who is waiting. Rendering it +as `pending` with an empty question shows a heading with nothing under it, which reads as a broken +gate rather than an absent request. It is `pending-unstructured`, it says +"Gate pending, no structured request", and both the derivation and the panel have tests that fail +when it collapses into either neighbour. + +"Structured" means there is something to READ, not that a field was sent: a gate carrying only +choices, or only a question, is structured. A gate carrying neither is not. + +### It is not folded into session status, and the hue matters + +`starting` / `running` / `ready` / `settled` describe what the AGENT is doing, and none of them can +say "a human has to decide". `hasPendingApprovals` cannot stand in either — that is provider TOOL +approvals, and the contract already records why the two must stay apart. + +The row marker therefore has its own derivation and its own colour. Amber is Pending Approval, +indigo Awaiting Input, sky Working, violet Plan Ready, emerald Completed; reusing amber would +collapse exactly the distinction the gate block exists to make, so the gate is rose. A test asserts +the pill uses none of the five taken hues, which is the only way that claim survives a later +refactor. + +It also sits OUTSIDE the status slot, which fades to make room for the row's hover actions. A gate +that vanished when someone reached for the row would be missing precisely when it was being acted +on, and no screenshot taken at rest would show it. + +### The XSS test that gets quieter as the defect gets worse + +Gate text — the question, every label, every consequence, the terminal excerpt — is written by a +builder agent into `status.yaml` and carried over a socket. A panel that rendered any of it as +markup would let a repository under review script the page reviewing it. + +The obvious test renders a payload and asserts the markup contains no `/state` carries `porch.phase` and the last three messages per +identity. That is where `apps/client` reads them. So the plan's ban on six continuous subscriptions +was never in tension with showing them, and no contract change was needed. + +The lasting correction is the wording. "Phase not published" is a claim about the world and it is +false; the true sentence is that this page cannot reach codev-agent yet, which phase 10 fixes. A +pane asserting data does not exist when the pane simply cannot see it is this project's most-caught +defect, and it nearly shipped again in the words rather than in the code. + +### A criterion the spec dropped, restored because a screenshot argued for it + +Spec 250 restated spec 146's criteria 5 and 5b and never restated 4b — the architect does not take +an equal tile where that makes a ragged row. Nothing in this plan was broken by the omission, and +the first 1440 screenshot was the argument: six builders and an architect at three columns is +3 + 3 + 1, one lonely card beside two empty slots. It is in the plan as a criterion now, so the next +reader checks it rather than remembering it. + +The interesting part is the number. Spec 146 states 4b as "1920 or wider", and implementing that +literally here would have been **wrong**: `apps/client` owns the whole viewport, so its viewport +width and its available width are the same number, while this grid sits behind a sidebar where 1920 +of viewport is 1688 of grid. A viewport threshold would offer the tile at 1920 with the sidebar +dragged wide enough that only three columns fit — the exact defect the criterion exists to prevent. + +So it is stated as "four columns fit", which is not a proxy for the reason but the reason itself: +seven items at four columns is 4 + 3, the ordinary shape of any grid. It gives what 4b names at both +viewports 4b names, and it is right at a 1600px window with a collapsed sidebar, where the literal +reading would wrongly withhold the tile. Spec 146's wording stays as it is — `apps/client` is frozen +and still owns its viewport, so it is still true there. + +Raised before building rather than after, and the architect ruled on the departure rather than on +the framing. + +### A test that could not fail, and the fixture that fixed it + +The architect asked whether an architect TILE stays distinguishable from a builder tile in the +multi-architect case, where there is no strip, no indent and no rail — the role prefix is the whole +distinction. It could be clipped: the prefix lived inside the truncating span, so a long enough +title in a narrow enough pane would eat it. + +Two things about the check that catches it. It measures `scrollWidth` against `clientWidth` rather +than reading text, because `text-overflow: ellipsis` is invisible to a text assertion — the DOM +still holds `builder/`, so `toContainText("builder/")` passes on a prefix rendered as `buil…`. And +it could not have failed as first written: six short fixture titles never fill a pane, so a prefix +that COULD be clipped never was. + +One fixture builder is named long enough to truncate now. Verified by reverting: that pane clips its +prefix by 22px, and the text assertion still passes. Real threads are named `builder/spir-250 gate +rendering in t3code` and worse, so the long title is the realistic case rather than a contrivance. + +### What can a human see or do now that they could not before + +Open one screen and watch six builders at once inside t3code, each pane naming the agent by role, +its status, and the gate it is stopped at if it has one — a clean 3x2 at 1440x900 with every pane +over 340x240 and the architect on a strip below it that expands on demand, seven equal tiles in four +columns at 1920 where that is not ragged, and on a phone pages of two rather than seven panes +squeezed under the readable floor. + +## Phase 10 — Approval from t3code over the same-origin proxy + +### The guarantee is structural, and the test the plan first proposed would have passed vacuously + +The spec's Security section and the plan's first draft both said `connect-src 'self'` "stays +closed". t3code sends `Content-Security-Policy` on `.svg` asset responses only +(`apps/server/src/http.ts`), and `apps/web/index.html` carries no meta tag. **There is no +page-level CSP and therefore no `connect-src` directive to keep closed.** A test asserting one +would have been green against a header nobody sends — a check that cannot fail, on the security +property of the phase. + +What actually holds is narrower and stronger: the page never *makes* a cross-origin request, +because it has no absolute URL to make one with. `agentUrl` returns a path, and +`pairing.test.ts` asserts that with `expect(() => new URL(url)).toThrow()`. The browser test +then records **every request the page issues** while it pairs, reads workspace state and +approves, and asserts each one is on t3code's origin — plus one assertion that it reached the +proxy at all, without which the first would pass on a page that made no agent request. + +Adding a page-level CSP is deliberately not done. It changes how every t3code page loads, which +is far wider than this spec's diff, to obtain a guarantee already held. + +### The target is configured, and a route-path allowlist would not have been enough + +The plan's own most consequential item, found in review round 1: the deliverable said the web app +"holds the `codev-agent` origin", and a server proxy forwarding to a browser-named origin is an +SSRF primitive. A path allowlist does not constrain the *host*. + +So the operator configures `T3CODE_CODEV_AGENT_ORIGINS` as `id=origin` entries and the browser +selects by **id**. The origins never reach the page — `/api/codev/agent-targets` answers ids +only, and the e2e asserts the agent's port does not appear in the body. A URL arriving where a +path belongs gets its own refusal (`CODEV_AGENT_PATH_ABSOLUTE`) rather than falling off the end +of the allowlist, because "this is not a path" and "this path is not carried" send a reader to +two different places. + +Three unconfigured/misconfigured states get three signals, ported from `client-static.ts`'s +lesson: `CODEV_AGENTS_UNCONFIGURED`, `CODEV_AGENTS_ALL_REJECTED`, and a usable list with the +rejected entries reported beside it. An operator who typed a bad origin and one who configured +nothing need opposite next actions. + +### An allowlist for headers, and the `Connection` subtraction on top of it + +`client-static.ts` builds its strip set from a fixed hop-by-hop list *plus the tokens the +request's own `Connection` header names*, because that header names headers that are themselves +hop-by-hop. This port inverts the default — an **allowlist** of what may travel, since this hop +sees credentials and a denylist forwards everything nobody thought of — and still subtracts the +`Connection` tokens, because an allowlist alone forwards a header a request declares +connection-scoped, and here that header is the machine credential. Both mechanisms are exercised; +removing the subtraction fails the test named for it. + +`authorization` and `cookie` are absent from the allowlist and that absence is a deliverable: +t3code's own session gates USE of the proxy and is never handed to another server. + +### Two failure signals, and a redirect that is refused rather than passed on + +An unreachable host and one that accepted the connection and said nothing keep separate signals, +driven against real sockets — a closed port and a server that accepts and never answers. A 3xx +from the configured origin is refused (`CODEV_AGENT_REDIRECT_REFUSED`): forwarded to the page, +the browser would follow it, cross-origin, with the request's credentials, which is the escape +the configured-target rule closes one hop earlier. + +Three of `codev-agent`'s routes are deliberately not carried. The SSE stream, because this proxy +buffers and a buffered stream is live on the wire and empty in the page. Both revocation routes, +because `afx pair revoke` is the operator path and a browser that could revoke could deny a human +their own gate. + +### Three defects the browser caught and the tests could not + +Every unit test in this phase was green through all three. + +**The page read the agent store once and froze.** Pairing succeeded, the credential reached +browser storage, the poll ran and returned 200 — and the panel still said this browser holds no +codev-agent credential. A hand-rolled subscription (a `useState` tick pushed from a module-level +listener set) rendered the first snapshot and never followed the store again. `useSyncExternalStore` +is the primitive for this shape, with a snapshot **replaced** rather than mutated so its identity +is the change signal. This cost more time than anything else in the phase, and nothing but a +browser could have found it: every function involved is individually correct. + +It also could not be debugged from inside the fork. `start-fork` refuses a dirty checkout — by +design, and the right design — so instrumenting the component for a browser session is not +available without committing. The fix came from replacing the mechanism rather than from +observing it. + +**A gated pane dropped the phase it had just gained.** The gate replaced the phase line, which +was right in phase 9 when there was no phase to show and wrong the moment `codev-agent`'s +projection arrived. A reader who has found the pane that wants them still needs to know what it +was doing. The gate leads in rose with its gavel; the phase follows. + +**`Send a message to start the conversation.` printed across `Waiting on you: `.** On a +thread with no turns, t3code's empty-timeline placeholder is centred over the whole content column +and lands on the gate panel. **Present since phase 8** — the phase 8 panel-only screenshot could +not show it, and the full-page one did. Hidden through upstream's own `hideEmptyPlaceholder` +rather than moved, because it is also wrong advice: a thread waiting on a human approval is not +waiting for a message. + +That is the second time in this spec that a cropped screenshot passed something a full-page one +would have caught, and the lesson is the screenshot's framing, not the reviewer's attention. + +### Pane content landed here, and the contract was not extended for it + +The architect's ruling, recorded in the plan because the phase's file list predates it. Phase 9's +panes said "Phase not read here yet — published by codev-agent", which was true: `codev-agent` +has published the porch phase and the last three messages workspace-scoped since phase 6, in ONE +request for the whole grid, and no page could reach it. The proxy is the way in, so the panes read +it here. + +The project id the approval needs comes from that same snapshot, which is why the two arrived +together. A project id on the gate block would have been a second copy of a fact `codev-agent` +already owns, and two copies can disagree. + +Every branch that still cannot show a phase names which one it is: not paired, the agent could not +be reached, the agent answered and does not publish this thread, or it published no porch project. +A blank line for all four would be a claim about the builder rather than about what reached the +browser. The message log keeps its three states too — an agent predating the field HAS messages it +is not sending, and that is not "no messages". + +### Testing the seam, twice, because one test cannot make both claims + +`spec-250-t3code-approval.e2e.test.ts` drives the REAL fork server's proxy in front of a real +`agent-routes` host, over `fetch`, ending in a real `status.yaml` — criterion 4. Nothing is +imported and no proxy function is called: the test reaches the route the only way a browser can, +so a route registered nowhere fails it. The unit tests can prove `forwardableHeaders` strips a +header; only this can prove anything is wired to `forwardableHeaders`. + +`spec-250-approval.spec.ts` makes the claim a `fetch` cannot: what a real page asks for. + +Both share `spec-250-agent-host.ts` rather than each building a host, so they cannot drift into +testing two different services. It seeds identities AFTER start, because the order is a circle +broken in one place: the fork server needs the agent's port in its environment at start, and the +thread ids the identities carry do not exist until that server is running. + +### Falsifiability, and the one that is about configuration rather than code + +Reverting five mechanisms — the `Connection`-token subtraction, the header allowlist, the redirect +refusal, the credentials-in-URL rule and the anchored route pattern — fails five unit tests. + +The e2e's check is different in kind and worth naming: pointing the configured allowlist at a dead +port fails 4 of its 7 tests. That is what proves the ceremony travels the configured proxy and not +some other path that happens to work — a shape the unit tests cannot express, because the thing +under test is the wiring. + +### The review found a test that reported a pass on a run that never happened + +opencode's `REQUEST_CHANGES`, and the most valuable finding in the phase. The vitest e2e's +availability guard logged a warning and RETURNED, which vitest records as a **pass** — so on a run +where the fork server never started, criterion 4 and every SSRF refusal reported green with not one +assertion executed. + +This project keeps finding "I could not tell" spelled as "no". This is the same defect spelled as +**"yes"**, which is strictly worse, and it was on the phase's own acceptance criterion. The file's +header states the rule — "Skips, never passes" — and the code broke it, which is the durable +lesson: a header is not a mechanism, and a rule written next to code that does not implement it +reads as reassurance. + +It was also invisible to me. Every run I did had the fork up. It would have surfaced the first time +anyone ran the suite without `T3_NODE`, as a green tick. + +`ctx.skip` marks the test skipped and does not return, so the body is unreachable rather than +merely unexecuted. Demonstrated rather than asserted — same file, same command, `T3_NODE` unset: +**8 passed** before, **8 skipped** after. + +### Both lanes found the same coarse attribute, independently + +`data-codev-approval-state` computed three values over four outcomes, so `sessionEnded` tagged as +`refused` while the visible text and testid distinguished it. Nothing asserted on it, which is +exactly why it was worth fixing before anything did: the first test written against the attribute +would have inherited the conflation the file's own header exists to prevent. + +Two lanes reaching it separately is the signal that it was not a stylistic note. + +### Every phase 10 deliverable, and what holds it up + +Written out because "the deliverables are met" is the sentence that hides the one that is not. + +| Deliverable | Held up by | +|---|---| +| Machine credential AND `client-session`, neither alone | `spec-250-t3code-approval.e2e.test.ts` — the two refusals asserted `not.toBe` each other | +| The page holds the target and the workspace path | `pairing.test.ts` round-trip; the e2e drives the real ceremony with both | +| Both travel the existing ceremony | The e2e mints through `PairingStore` with the real purposes; the Playwright spec types into the real form | +| t3code's session is never an approval credential | `agentProxy.test.ts` "never forwards t3code's own session"; the e2e sends a valid bearer with no machine credential and gets `MACHINE_CREDENTIAL_REQUIRED` | +| Credential in per-origin browser storage, with the trade stated | `pairing.test.ts` — three storage states, including a store that throws | +| **No cross-origin request, asserted by watching the network** | `spec-250-approval.spec.ts` records every request and asserts `foreign === []`, plus a positive assertion that the proxy WAS reached | +| No page-level CSP is added | Not done, and recorded here and in the plan as deliberate | +| **Upstream target server-configured, never browser-selected** | `readCodevAgentTargets` tests; the e2e's SSRF block; the targets route answers ids and the test asserts the agent's port is absent from the body | +| Scheme/address rules, no credentials in the URL, redirects not followed | `originProblem` tests (5); the redirect refusal driven against a real 302 | +| **Hop-by-hop stripping is dynamic** | The `Connection: keep-alive, X-Codev-Machine-Credential` test, which fails when the subtraction is removed | +| Two proxy failure signals | Two socket-level tests: a closed port and a server that accepts and never answers | +| **The approval record comes from the server** | `approval.test.ts` "reports a success it cannot read as unconfirmed"; criterion 4 asserts the same three fields in `status.yaml` AND in the response | +| Four outcomes, not three | Four branches in `approval.test.ts`, plus `approvalStateAttribute` mapping four to four | +| The ceremony named in full | The e2e walks all four requests in order, each through the proxy | +| Tests for this phase | 28 proxy unit + 46 web unit + 8 vitest e2e + 6 Playwright | + +Two things beyond the list, both found while building rather than planned: the **unbounded request +body** (Effect's `MaxBodySize` defaults to unbounded on a route that buffers) and the review's +finding that the e2e **reported a pass on a run that never happened**. + +### What can a human see or do now that they could not before + +Approve a porch gate from t3code, on a phone or an iPad, without a terminal: pair the browser once +with a token from `afx pair issue`, spend a session token, and press Approve — and porch writes the +approving session id, machine and timestamp into `status.yaml`, all three read back from the server +rather than invented in the page. And on the Builders screen, see what each builder is actually +doing — its porch phase, its plan phase and the last three messages its architect sent it — where +three phases of panes had said only that the data existed somewhere else. + +## Phase 11 — Acceptance run and the rebase drill + +### The drill measures; it does not perform a rebase we keep + +Criterion 9's wording — "the fork rebases onto a later upstream commit named in `pin.json`" — reads +as an instruction to advance `upstreamBase`, and following it literally would have spent the +evidence base. The moment `pin.json` names a new base, `verify-upstream` expects the preserved +clone to BE there, and every spec 146 and spec 236 result tied to `082e6ea52186` stops being +re-runnable. + +So the drill runs on a **throwaway clone** and the real pin does not move. Both the plan and the +spec's criterion 9 carry that amendment with the reason, because the next person to read the +original sentence would otherwise do the literal thing. + +**The read-only order held, and the drill checks rather than promises it.** It re-reads both +checkouts after each run and **discards its own result** if the preserved upstream left its base, +if the fork head moved, or if `pin.commit` changed — a drill that disturbed the thing it was meant +to leave alone cannot be trusted about anything else. A `git fetch` was the one write, and it is +the permitted one: remote-tracking refs move, HEAD does not, verified before and after. + +### A rebase stops at the first conflict, so the first conflict understates the job + +This is the design decision worth keeping. `git rebase` is sequential: it reported "stopped at +commit 6 of 42 on one file" and that answers *where does it stop*, not *how much conflicts*. A drill +that reported only that would understate every rebase it ever measured, and would do so in the +reassuring direction. + +So the drill also three-way-merges the same two trees and aborts immediately — one pass, every +conflicting file. **3 of the 35 files we modify**, against upstream 104 commits ahead. + +Two questions, two numbers, and neither is a substitute for the other. + +### The prediction was wrong in the interesting direction + +`FORK.md` rated `packages/contracts/src/orchestration.ts` **High** — it is the file upstream changes +most, and `classify-churn` found upstream had touched it twice in exactly the two unions our +customization extends (`subscribeThread`, `dispatchCommand`). It **auto-merged clean**. + +What conflicted was `apps/server/src/server.test.ts`, an upstream **test** — the half `FORK.md` +already warned is easiest to forget when estimating the drill, now demonstrated rather than +asserted. The risk table carries measured beside predicted; where they disagree the measurement +wins. + +### The watermark invariant finally had a real migration to bite on + +Phase 2 tested "a new upstream migration landing after the guard still runs" with a synthetic +migration. In the 104 commits since, upstream shipped a real one — `043_ProjectionThreadsUnsettledAt` +— above the `042` our base leaves. Codev writes nothing to `effect_sql_migrations`, so the watermark +is whatever upstream last ran, and 043 runs. + +The check is stated as the invariant rather than as the number: every migration upstream adds must +have an id above the watermark our base leaves. `checked: false` is its own state and is **not** a +pass, asserted in the evidence test so an unreadable migration directory cannot masquerade as a +holding invariant. + +### `apps/client` was red, and had been since phase 5 + +The phase's deliverable is "confirmed frozen and still green". Frozen was true — zero files changed. +Green was not: 278 of 279. + +Phase 5 regenerated the vendored contract **from the fork**, our `codevGate` object landed ahead of +the session object in the generator's numbering, and the session-status enum moved from +`$defs.subscribeThreadOutput__Objects_6` to `_7`. `derive.test.ts` still read `_6`. + +**The assertion message is why this cost a minute rather than an hour.** It said: *"the generated +contract no longer declares the session status enum where this test reads it. That is this test +needing a new path, not a mapping change."* A stale read path and a broken status mapping look +identical at the failure site, and `expected undefined to be defined` alone would have sent a +reader into `deriveRowStatus`. That is what a failure message is for, and most in this repository +would not have done it. + +**The freeze authorised the fix rather than forbidding it** — "frozen means it keeps passing its +tests and receives fixes, not that new front-end features land in both places". A fallback whose +suite is red is not a fallback: the whole reason `apps/client` is kept is that if this path fails +there is still something that works, and *works* is a claim its suite is the only evidence for. + +**The real gap is that nothing local runs it.** The root `npm test` filters to `@cluesmith/codev`, +so `apps/client`'s suite had not run since phase 5. CI would have caught it at PR time, which makes +this a near miss rather than a hole — but "the frozen fallback's suite runs only in CI, and only +once a PR exists" is too long a loop for the one package whose job is to still work. Filed as +**#265**; deliberately not fixed here, because changing the root test command touches every +contributor's inner loop. + +### Criterion 6 closes UNMET, and that is a result + +No iPad was available. It closes unmet with a stated reason and an executable runbook — not passed +on a simulation, and not left open. + +The runbook was worth more than the hour it took, because **verifying it against the fork rather +than writing it from memory caught three wrong instructions**, and one of them would have sent the +human to the wrong server entirely: `t3-server.mjs start-fork` starts on a throwaway data directory +with empty data, which is right for the tests and exactly wrong for a criterion that says a builder +is driven to completion. The other two were a variable that does nothing where I put it +(`T3CODE_CODEV_AGENT_ORIGINS` on the Vite command; the backend reads it) and a tailnet mode I +hand-rolled that t3code already ships (`pnpm dev:share`). + +The Playwright suite is **not** recorded as a substitute. It drives the same proxy and the same +ceremony, so it covers the approval path; what the iPad closes is tailnet reach and touch targets, +and nothing on the Mac tests either. + +### The third finding of one shape in two phases, and all three were mine + +Both lanes, independently, found that the drill's `ok` outcome documented "rebase clean, contract +regenerated, shape-check held" while the clean branch called neither tool — and that +`regenerate-failed` and `shape-check-failed` were documented outcomes assigned nowhere in the file. +claude REQUEST_CHANGES/HIGH, opencode COMMENT/HIGH; the stricter reading is the one that was acted +on. + +**The family is now three, across two phases.** A guard that logged and returned, which vitest +records as a pass. `startsWith` as a same-origin assertion, unable to fail across ten ephemeral +ports. And a comment describing work no code does. The common cause is not carelessness about +tests. It is that **the claim gets written in prose while the mechanism is being built, and the +prose is what gets re-read when checking the work** — and it is always right, because the same hand +wrote both. The remedy that generalises is a check that reads the artifact rather than the intent: +`documents exactly the outcomes it can assign, and no others` extracts the vocabulary from the +header comment and from the `outcome:` assignments and asserts set equality. A prose-only fix cannot +fail that test. + +### The reviewer's cheap option was unreachable, and finding that out changed the fix + +The suggested fix was to run the generator and `shape-check` in the clean branch, making the two +dead outcomes reachable. It cannot be done. `generate.mjs` refuses any checkout whose `HEAD` is not +`pin.commit`, and a rebased tree never satisfies that: its head is a commit that did not exist +before the rebase. Regenerating from one means moving the pin, which is the adoption the drill +exists in order not to perform. + +So the choice was not "two lines or a comment edit". It was between narrowing the header — honest, +and leaving criterion 9 answered by `regenerationReachable`, a boolean that only says the generator +would FIND its source — and measuring something real without running the generator. Both were done. +`contractClosure.sourceHash` hashes the closure off the merged tree and compares it to +`generated/source-hash.json`, which `generate.mjs` itself argues is the load-bearing drift detector +because the emitted schema is blind to constraints behind a `decodeTo` transform. + +**The measurement changed the answer, which is the only thing that justified taking it.** Zero +closure conflicts, so regeneration is not blocked — and **4 of the 9 closure files come out of the +merge with different bytes**, so the regenerated contract would not be the one vendored. +"Regenerable" and "unchanged" had been reading as a single fact in `FORK.md`, in `REFRESH.md` and in +the acceptance evidence. All three now carry both. + +### The same tautology had two doors, and only one was obvious + +The hash has to be taken while the merged worktree is on disk, before `merge --abort`. Taken after, +the worktree is the fork again and the comparison is the fork against itself. That was checked +rather than assumed: hashing the unmerged fork against `source-hash.json` reports `moved: []`, which +is what the post-abort version would have published on every run, looking exactly like good news. + +The second door was found re-reading the fix, and neither lane raised it. Guarding on +`closureConflicts.length === 0` is the right question **once a merge has happened**. A `git merge` +that refuses to start — already up to date, a wedged index — leaves the worktree as the unmerged +fork with no conflicts to notice, and walks into the same comparison through a different branch. + +That decision is `closureMeasurability`, and it lives in `tools/t3-fork/drill-closure.mjs` rather +than inline. `rebase-drill.mjs` is a script — importing it runs a drill against two real checkouts — +so an inline guard is covered only by whatever branch the last real run happened to take, which is +precisely the wrong coverage for a guard whose job is to fire on a case no normal run reaches. Five +unit tests reach it; deleting the guard fails two of them. + +### A number that is typed is a number that will be wrong + +opencode's third point: `104 commits, of which 5 touch the pinned closure` was prose, in the +document whose own opening paragraph explains that hand-typed numbers rot, next to a collector built +to stop exactly that. The drill now counts both from the preserved clone over the same range it +rebased across — so the churn and the conflict surface can never describe two different ranges — and +the collector prints them. `null` renders as "not counted", never as `0`. + +The counted values matched what had been typed. That is the outcome that makes this worth recording: +the fix was not prompted by a wrong number, and the next drill is where a typed one would have gone +wrong silently. + +### The e2e re-run, and the run that looked like a pass + +claude's non-blocking note: the phase 11 regression run excluded `**/e2e/**`, so criteria 1, 2, 3, 5 +and 5b rested on the phase 7-10 Playwright runs. Phase 11 adds no fork commit, so `pin.commit` is +still phase 10's head and those runs were already at the final fork head — but 2.3 minutes buys a +run instead of an argument. **32 passed.** + +**The first attempt reported `32 skipped` and exited 0.** `T3_NODE` was unset and the fixture +refuses to start the fork server without it. That is phase 10's own lesson working as built: a skip +carrying its reason rather than a pass. It is also why the evidence row says "32 passed" and not +"the suite is green" — a run that exits 0 having executed nothing is the failure this phase is +about, and it turned up one more time in the phase that fixed it. + +### The gap an amended criterion was hiding, and it was an hour to close + +**The architect asked the question that the whole phase had routed around.** The drill proved the +rebase *measures*. It did not prove the contract still *regenerates* after one — and that is the +claim that matters on the day a new base is adopted, which is the worst possible moment to find out +it does not hold. Two iterations of review had passed over it, because every statement about it was +true: the generator refuses a tree whose HEAD is not `pin.commit`, regenerating means moving the pin, +the drill exists in order not to move the pin. All true, and it added up to a criterion that read +"met" while the interesting half was deferred. + +The way out was not to loosen the guard. It was to satisfy it somewhere disposable. `git merge-tree +--write-tree` and `commit-tree` give the merged tree an identity **inside the throwaway clone** — +which matters because the sequential rebase stops at commit 6, so there is no rebased HEAD at all; +the generator reads only the closure, and the closure merges clean. Then a **scratch codegen root**: +`generate.mjs` resolves `pin.json`, its output directory and its staging area from its own file +location, so a copy of the tool under a scratch directory reads a scratch pin naming that commit. +The guard is met honestly — the artifacts really are reproducible from the commit they name. + +**The contract regenerates, and `schema.json`, `schema.ts` and `types.d.ts` all move.** So adopting +this base changes the shapes Codev consumes, and that is now a measured fact with an artifact list +rather than an open question deferred to the first real rebase. + +Three details that are the difference between this being evidence and being decoration: + +- **The comparison is against what is vendored in this repository**, never against what the scratch + run just wrote. The second is a tautology, and it is the third time in this phase that the + tautology was the thing to design against. +- **A regenerated contract that differs is a result, not a failure.** Same argument as `conflicts`. + So the outcome vocabulary stayed three words and the finding went into `contractRegeneration` with + its artifact list. Widening the vocabulary would have re-created the defect iteration 1 found. +- **The generator needs Node 22 and the drill runs under 20.** A wrong interpreter reports + `NO_INTERPRETER`, never "the contract does not regenerate" — the second is a claim about the fork + made from a fact about this machine, which is the "I could not tell" rule at its most literal. + +### What can a human see or do now that they could not before + +Know what carrying this customization onto a newer t3code actually costs — three files, named — +instead of guessing from a risk table; and re-run that measurement any time with one command, +against a fork and an upstream clone that the measurement provably did not disturb. And read, from +the same run, what the contract that comes out the other side actually looks like: it regenerates, +and three of its shape artifacts move. + diff --git a/codev/specs/250-t3code-front-end-customization.md b/codev/specs/250-t3code-front-end-customization.md index b247cba32..03c773059 100644 --- a/codev/specs/250-t3code-front-end-customization.md +++ b/codev/specs/250-t3code-front-end-customization.md @@ -303,6 +303,22 @@ client instead. therefore reported as `NO_UPSTREAM_MOVEMENT` and passes**, distinct from the tool failing or reading the wrong ref, which do not. Criterion 9 is satisfied by the procedure running and reporting one of those three, never by an unexplained zero. + + **AMENDED 2026-08-31, at the architect's direction. The wording above stands, and this is + how it is executed.** "Rebases onto a later upstream commit named in `pin.json`" must NOT + be read as an instruction to advance `upstreamBase` — the drill runs on a **throwaway + clone** and **`pin.json` is unchanged**. + + Why: the moment `pin.json` names a new base, `verify-upstream` expects the preserved clone + to BE there, and every spec 146 and spec 236 result tied to `082e6ea52186` stops being + re-runnable. Advancing the base is a decision taken when there is a REASON — a security + fix, a feature we need — never as a phase deliverable. `/Users/chris/dev/t3code` stays + read-only: a `git fetch` is fine, because remote-tracking refs move while HEAD and the + working tree do not; a checkout is not. + + **The criterion is met by the procedure completing and reporting**, not by adopting a new + base. `tools/t3-fork/rebase-drill.mjs` re-reads both checkouts afterwards and discards its + own result if either moved. - [ ] 10. An approved gate cannot be re-displayed by a later write, proved by clearing a gate and then delivering a write carrying a lower revision. - [ ] 11. Hierarchy integrity. Each of these is **refused by the server at write time** — not @@ -338,7 +354,7 @@ client instead. | Risk | Probability | Impact | Mitigation | |---|---|---|---| | **Churn detection goes blind** — `classify-churn --since` pointed at the fork head compares our tree to itself and reports no churn forever | High | High | It consumes `upstreamBase`; a test asserts non-zero churn against a known-moved upstream | -| **Rebase-time migration collision** — upstream adds a migration at the same version | Medium | High | Number ours far above upstream's range and assert the gap at rebase | +| **Rebase-time migration collision** — upstream adds a migration at the same version | Medium | High | **Superseded 2026-08-30 by the plan-gate ruling.** Codev's columns stay OUT of upstream's numbered registry entirely: a guarded `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` (upstream's own idiom, per `042_ProjectionThreadLinkedPullRequest.ts`) invoked from a layer sequenced after `MigrationsLive`. "Number ours far above upstream's range" is obsolete and was unsafe: `effect` `Migrator.js:121` skips any id `<= MAX(migration_id)`, so a high id silently shadows every later upstream migration, and any id we occupy collides once upstream reaches it. A separate layer cannot collide. Cost: our columns are absent from upstream's migration history, mitigated by a named start-up log signal | | **Stale gate write recreates an approved gate** | Medium | High | The revision high-water mark survives the clear; criterion 10 delivers a stale revision after approval | | Contract regeneration drifts from the fork | Medium | Medium | Criterion 9 makes regeneration part of the rebase, not a follow-up | | The fork becomes unmergeable | Medium | High | Keep the diff narrow: two record fields, one gate block, sidebar and tiling. No refactors | diff --git a/codev/state/spir-250_thread.md b/codev/state/spir-250_thread.md new file mode 100644 index 000000000..e1cf2dc53 --- /dev/null +++ b/codev/state/spir-250_thread.md @@ -0,0 +1,1899 @@ +# spir-250 — t3code is the front end + +## 2026-08-30 — plan phase + +Spec 250 arrived approved (frontmatter `approved: 2026-08-30`, validated by claude + codex), so +porch handed me the plan phase directly. + +### What I verified before planning, rather than taking the spec's word for it + +- `/Users/chris/dev/t3code` is at `082e6ea52`, clean, remote `pingdotgg/t3code`. Matches + `pin.json`'s `commit`. +- `/Users/chris/dev/t3code-codev` **does not exist yet**. The fork is phase 1's work, not a + precondition someone already met. +- `OrchestrationThreadShell` (`packages/contracts/src/orchestration.ts:469` in the clone) has no + parent field and no metadata bag, as the spec says. `ThreadCreatedPayload` at `:1148` carries + only threadId/projectId/title/model/runtime/interaction/branch/worktreePath/timestamps. +- Upstream's live migration range ends at **42** (`042_ProjectionThreadLinkedPullRequest`). That + is why the plan numbers ours at 900/901 — the spec said "far above" without naming a number. +- Upstream scopes are `orchestration:read`, `orchestration:operate`, `terminal:operate`, + `review:write`, `access:read/write`, `relay:*`. Nothing expresses gate-writing, confirming the + spec's `codev:gate-write` addition is necessary rather than convenient. +- `codev-agent`'s route prefix is `/api/agent/v1`, and the route table already carries + `gate-approve`, `approval-submit`, `session-probe`, `pairing-redeem`, `human-session-issue`. + Phase 10 proxies to these; it does not invent a new approval surface. +- t3code is `pnpm@11.10.0`, `engines.node: ^24.13.1`. + +### Decisions I made that the spec left to the plan + +1. **Migration numbers 900 and 901.** Spec said "number ours far above upstream's range"; 900 + with upstream at 42 gives an 858-migration gap, and a test asserts upstream has not reached it. +2. **`T3CODE_FORK_ROOT` as a second variable.** The spec ruled the identities explicit but named + no variable. Stretching `T3CODE_ROOT` over both is exactly the failure it warns about. +3. **`tools/t3-fork/patches/` as a review aid.** The fork's commits cannot appear in this repo's + PR, so a reviewer would otherwise have no diff for the six changes. Stated in the plan as a + review aid only — approach 1 (patch set applied to a checkout) stays rejected. +4. **Eleven phases.** The vendoring harness is built first, while fork HEAD still equals + `upstreamBase`, so its assertions have known answers before any customization exists to hide a + harness bug inside. + +### Flagged for the architect + +Phase 1 runs `gh repo fork pingdotgg/t3code` — creating a **public** fork under `pseudoseed`. +Outward-facing and not quietly undoable. The spec bakes the destination, so it is decided, but +the act itself is worth a look before it happens. + +### Claims verified while the plan consultations ran + +- `afx pair` exists with `--purpose machine-credential | client-session` + (`packages/codev/src/agent-farm/commands/pair.ts:55`), and `--purpose` has no default. Phase + 10's ceremony is real, not assumed. +- `afx pair revoke ` exists and revokes the credential plus its live approval + capabilities (`pair.ts:319-324`). The plan's revocation acceptance criterion is checkable. +- The spec's "one variable feeds six consumers" is exactly right: `T3CODE_ROOT` is read by + `t3-server.mjs`, `smoke.mjs`, `classify-churn.mjs`, `transform-blindness-probe.mjs`, + `generate.mjs`, and `spec-146-t3-contract.test.ts`. Six. +- `verify()` in `t3-server.mjs:100-140` compares HEAD to `pin.commit` and refuses a dirty tree, + with `UNDETERMINED` as its own exit. Phase 1's two-identity change extends this rather than + replacing it, and exit 3 stays distinct. + +### opencode lane failed silently on the first attempt + +Run 1 of `consult -m opencode` **exited 0 and wrote no review file**. Its stdout shows +`permission requested: external_directory (/Users/chris/dev/t3code/*); auto-rejecting`, then a +failed glob, then two successful reads, then exit. An exit 0 with no verdict is the exact shape +lessons-critical.md warns about — "I could not tell" spelled the same way as "no". Not counted as +a review. Re-run in progress; if it fails the same way the lane gets reported rather than +silently dropped from the round. + +Both producers confirmed alive before waiting: `consult -m claude` (pid 34003) and +`consult -m opencode` → `opencode run -m xai/grok-4.6` (pids 38846/38936). + +### Fix for the opencode lane + +Root cause found rather than worked around: opencode auto-rejects `external_directory` +permission requests, and the plan cites `/Users/chris/dev/t3code` throughout, so the reviewer +died mid-run on the first read outside the worktree — twice, both times exiting 0 with no file. + +`opencode run --auto` exists but the consult lane does not pass it. `OPENCODE_CONFIG_CONTENT` +does the same job scoped to a single invocation, verified directly: + + OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"allow"}}' \ + opencode run -m xai/grok-4.6 -- 'Read /Users/chris/dev/t3code/package.json ...' + → pnpm@11.10.0 + +Stated plainly: `external_directory: allow` grants *any* external directory for that process, not +just the t3code clone. Acceptable for a read-only review lane on this machine; it is not a narrow +grant and is not described as one. No global config was edited. + +Worth raising with the architect separately: the consult opencode lane exiting 0 with no verdict +after a permission rejection is a lane bug, not a spec-250 problem. Two runs, same shape. + +## Plan review round 1 — claude lane, REQUEST_CHANGES + +The central finding was one I had got wrong, and I verified it before acting rather than taking +the review at its word. + +**Migration 900 would have silently disabled every future upstream migration.** Effect's migrator +is a watermark, not a set difference — `unstable/sql/Migrator.js:78` selects +`ORDER BY migration_id DESC` and `:121` does `if (currentId <= latestMigrationId) continue`. Read +in `node_modules/.pnpm/effect@4.0.0-beta.103/`, the exact version `pin.json` names. Registering +900/901 makes the watermark 901, so upstream's 043+ arrive below it and are skipped while the +migrator logs that the schema is current. My "number far above upstream's range" mitigation +converted a loud collision into silent schema divergence, and my proposed guard test ("fail if +upstream reaches 900") asserted the inverse of the right invariant. + +Fix: Codev's columns never enter `migrationEntries`. A guarded idempotent +`PRAGMA table_info` + `ALTER TABLE ADD COLUMN` runs at server start and never touches +`effect_sql_migrations`. + +**This contradicts a spec assumption** — "the added columns follow [t3code's migration] +mechanism". It is an Assumption, not a Constraint and not a Baked Decision, and it is the +assumption that produces the bug. Flagged at the plan gate rather than changed quietly. + +A side effect worth recording: criterion 8b would have passed **by construction** under the +migrator route, because `Migrator.js:142` wraps the run in `sql.withTransaction` and SQLite DDL is +transactional. Outside the migrator there is no wrapper, so the kill test now discriminates. + +### Other verified findings + +- `spec-146-t3-contract.test.ts:254` fails if the cold-start evidence is older than + `t3-server.mjs` — which phase 1 edits. Re-collection is now a phase-1 deliverable, and the + assertion is not loosened. +- `:231` asserts `evidence.pinnedCommit === pin.commit`, which phase 5 breaks. Re-scoped to + `upstreamBase`: the evidence describes the upstream harness, so re-collecting it against the + fork would change what it is evidence of. +- Seven files read `T3CODE_ROOT`, not the three I listed. I missed + `packages/t3-client/live/integration.mjs:77` because my first grep was truncated at 20 lines. + All seven are now assigned to an identity in a table. +- `generate.mjs:78` refuses when checkout HEAD ≠ `pin.commit`; switching its root to the fork is + the load-bearing edit, which I had left implicit. +- Phase 10 understated both modules it ports: `client-static.ts:329-337` expands hop-by-hop + tokens from the request's own `Connection` header (a fixed list is insufficient), and + `approval.ts` has four outcomes — `sessionEnded` is distinct from `unconfirmed` and is ordinary, + since sessions idle out at 30 minutes. `approval.ts:300-316` also forbids manufacturing the + machine/session/timestamp client-side, which criterion 4 depends on. +- Phases 2, 3, 4, 7, 8, 9 touch only the fork, so each now logs its fork commit in `FORK.md` — + their only artifact in this repository. + +## Architect ruling — stay out of the numbered registry + +2026-08-30. Ruled with my finding rather than around it: because `schemaGuard` is upstream's own +PRAGMA-then-conditional-ALTER pattern verbatim, the only open question was registry membership, +and the answer is stay out. The reasoning worth keeping: a number we occupy is a number upstream +will eventually want, and that collision is silent — two entries claiming `043` means one is +skipped and its column never appears, which reads at runtime as "not recorded" rather than as a +failed migration. A separate layer cannot collide at all. + +Accepted cost, recorded rather than argued away: our columns never appear in upstream's migration +history. Mitigated with a named start-up signal, `CODEV_SCHEMA_GUARD_APPLIED` / +`CODEV_SCHEMA_GUARD_NOOP` — two signals, because "added two columns" and "had nothing to do" are +different facts. + +Spec risk row amended at `codev/specs/250-...md:341` under the architect's authority as approver. + +## Plan review round 1 — codex lane, REQUEST_CHANGES + +Substituted for opencode after three silent failures. Additive to claude's round rather than +overlapping it. All five verified before acting: + +1. **Gate revision was not implementable as written.** I had asserted both "codev-agent sends no + revision, the server allocates" and "a write carrying a lower revision is rejected". If no + write ever carries one, there is nothing to reject and criterion 10 has nothing to deliver. + Resolved by making `revision` optional: absent means allocate, present means must exceed the + mark or be refused `CODEV_GATE_REVISION_STALE`. +2. **`codev:gate-write` was unenforceable where I put it.** `RpcAuthorization.ts:24` maps the + whole `dispatchCommand` method to `orchestration:operate` — it scopes methods, not command + types, so a gate command routed through it would be reachable by every operator. Gate writes + now travel their own RPC method with its own row in that same map. +3. **My `t3-project-map.ts` would have been dead code.** `thread-backend.ts:442-450` already + resolves projects by `canonicalWorkspaceKey` and `:785-818` creates them, inside + `ensureThreadBackendReady`. Phase 6 extends that path instead. Also noted: `project.create` is + not idempotent (`:382`), so the existing single-flight guard is load-bearing. +4. **Persistence work named too few modules.** All four codex named exist and are now in phases 2 + and 4, with the start-up layer order asserted by a test rather than left to construction order. +5. **SSRF.** A server proxy forwarding to a browser-named origin is an SSRF primitive, and a + route-path allowlist does not constrain the host. Target is now chosen from a server-held + allowlist by id; absolute URLs refused, redirects not followed. + +## My own finding: the CSP claim was false + +Chasing codex's proxy finding I checked the CSP the plan and the spec both lean on. t3code sets +`Content-Security-Policy` on `.svg` asset responses only (`apps/server/src/http.ts:51,62`, +`default-src 'none'; style-src 'unsafe-inline'; sandbox`), and `apps/web/index.html` carries no +CSP meta tag. There is no page-level CSP and therefore no `connect-src` to "keep closed" — both +the spec's Security section and my plan asserted a header that is never sent. + +The same-origin design is unchanged and still right; the guarantee is structural, not enforced. +The test now records every request the page makes under Playwright instead of parsing a header. +Adding a page-level CSP is recorded as a follow-up, explicitly not done: it changes how every +t3code page loads, far wider than the spec's "keep the diff narrow" constraint. + +## My own finding: three phases planned tests with a tool the fork does not have + +Phases 7, 8 and 9 all said "verified under Playwright". t3code has no `playwright` in any +`package.json`, and `apps/web`'s entire test script is +`vp test run --passWithNoTests --project unit` with `@effect/vitest` as its only test dependency. +Criteria 5 and 5b are browser measurements — pane bounding boxes in CSS px, computed font size — +so a vitest unit test cannot close them; it proves the arithmetic in `columnsFor`, not that the +rendered pane is 340px wide inside t3code's chrome. + +Resolved by putting the harness in **this** repository, which already has +`@playwright/test ^1.58.0` in `packages/codev`, `apps/client`, `apps/v2` and +`packages/artifact-canvas`, driving the fork's dev server over HTTP. Two reasons in order: it +keeps the fork diff narrow, which the spec names as its unmergeability mitigation, and the +criteria are Codev's so the tests that close them belong in Codev's CI. + +Cost recorded: those tests need a running fork, so they are gated, and a skip is reported as a +skip rather than counted as a pass. + +## The opencode lane: my diagnosis was wrong, and so was my evidence + +Correcting the record rather than leaving it. I reported five runs "exiting 0 with no verdict" and +the architect filed #261 on that framing. + +**Every one of those runs was `consult ... 2>&1 | tail -15`.** In a pipeline the reported exit +code is the *last* command's, so "exit code 0" was always `tail`'s. Run clean, with stdout and +stderr redirected to files instead of piped, the same command returns **exit code 1**. + +So the lane was hard-failing loudly the whole time, exactly as designed — its own docs say missing +CLI, unknown model, non-zero exit and empty output all throw (`commands/consult/index.ts:1693`, +and #20 records why: porch counts a lane that produced nothing as an approval). The silent-lane +story was an artifact of how I invoked it. + +The real cause is in the stderr I had been discarding: +`permission requested: external_directory (/Users/chris/dev/t3code/*); auto-rejecting`. + +Also corrected: **porch does not invoke consult.** `porch next 250` emits a task whose text is +"Run: consult -m opencode ..." and I execute it — porch is a pure planner. So the "porch spawns +the child without your env var" theory describes a mechanism that does not exist here, and +exporting the variable before `porch next` changed nothing (verified: porch re-issued the +identical task). + +What remains real: `runOpencodeConsultation` sets `OPENCODE_PERMISSION` unconditionally at +`index.ts:1806` — `{...process.env, OPENCODE_PERMISSION: JSON.stringify(OPENCODE_READ_ONLY_PERMISSION)}` +— and `OPENCODE_READ_ONLY_PERMISSION` (`:1647`) covers only `edit`, `write`, `patch` and `bash`. +`external_directory` is not in it, so it falls back to opencode's default of ask, which +auto-rejects when non-interactive. Any review whose subject lives outside the workspace loses this +lane. That is the genuine #261, and it is narrower than what I first reported. + +`process.env` is spread first, so `OPENCODE_CONFIG_CONTENT` does reach the child — which is why my +run 3 read external files successfully. Why run 3 still produced no file is the open question the +clean re-run is answering now. + +## Plan review round 1 — opencode lane, REQUEST_CHANGES + +The lane finally ran once I stopped piping it through `tail` and kept the permission grant: 298s, +full review, exit 0. Keeping codex as well gave three reviews instead of two, and that paid for +itself immediately — opencode found a hole in the fix I had just made for codex's finding. + +Codex said `codev:gate-write` could not be enforced on `dispatchCommand`, so I moved gate writes to +their own RPC method. Opencode pointed out that a scope-map row alone does not compile: +`RpcAuthorization.ts:130` is `satisfies Record`, and +`WsRpcMethod` derives from `WsRpcGroup` in `packages/contracts/src/rpc.ts` — which pin.json +**deliberately excludes** from the vendored closure. So the method needs registering in four +places, and phase 4 now names all of them. + +**The most damaging finding in either round:** `acquire()` does +`gitIn(t3Root, 'checkout', '--detach', pin.commit)` at `t3-server.mjs:94`, against `T3CODE_ROOT` — +the read-only upstream clone. Once phase 5 moves `pin.commit` to the fork head, that checks a fork +SHA out into the clone the spec keeps pinned at `upstreamBase`, and `start` (`:389`) and `status` +(`:663`) compare the same way. `smoke.mjs:156` and `live/integration.mjs:196` both call `acquire`, +so it fires from an ordinary test run rather than a deliberate invocation. I had rewired only +`verify`. Phase 1 now rewires `acquire`, `start` and `status` too. + +Also: gate commands must stay out of `ClientOrchestrationCommand` / +`DispatchableClientOrchestrationCommand` (`orchestration.ts:935-987`) — those unions *are* the +`dispatchCommand` payload, so including them would hand gate-writing to every +`orchestration:operate` holder and bypass the new scope entirely. `ThreadSessionSetCommand` is the +internal-only precedent. And `generate.mjs:335` walks `pin.methods`, not `OrchestrationRpcSchemas`, +so the new method must be listed in `pin.json` or it is never vendored — the `vcs.*` entries are +there for exactly this reason. + +## plan-approval gate reached + +`porch done 250` passed all three checks and stopped at `plan-approval`. Structured gate request +recorded via `porch gate 250 --request-file`, architect notified. + +Round 1 ran three lanes — claude, codex, opencode — all `REQUEST_CHANGES`, all findings accepted. +Nothing in the disagree column, which is unusual and worth noting: two of the findings were errors +of mine that would have caused real damage, and opencode found a hole in the fix I had just made +for codex's finding. That is the case for three lanes rather than porch's two. + +Waiting on the human decision. The one item I flagged for a look before phase 1 starts is +`gh repo fork pingdotgg/t3code` — a public fork under `pseudoseed`, outward-facing and not quietly +undoable. The gate request offers the architect the option of creating it themselves instead. + +Commits this phase: +- `2882b2eb2` initial plan, 11 phases +- `6c1d5d870` claude round +- `8d25ac891` schema guard grounded in upstream's idiom +- `245296d5e` codex round + architect ruling, spec risk row amended +- `abf5f3d6a` browser harness moved out of the fork +- `987c5a416` porch-level lane note +- `46285ad8c` opencode round +- rebuttals committed separately + +## Architect ruling — private repo, not a GitHub fork + +2026-08-30, at the plan-approval gate. My phase 1 said `gh repo fork pingdotgg/t3code`, which was +wrong in a way I had flagged as merely "outward-facing" rather than as contradicting the spec. + +**A GitHub fork inherits the source repository's visibility.** There is no private fork of a +public repo, so forking would have published every customization to anyone looking, t3code's +authors included — the exact opposite of this spec's "private customization" ruling. + +Amended: `gh repo create pseudoseed/t3code --private`, with `origin` as the private repo and +`upstream` as `pingdotgg/t3code`. Rebasing works identically, which is the only capability the +plan actually needs. `gh repo fork` is recorded as a prohibition rather than a preference: it is +one command, and deleting the repo afterwards does not un-publish something that was indexable. + +Verified rather than assumed: t3code is MIT, `Copyright (c) 2026 T3 Tools Inc.`, so the private +copy keeps the licence and attribution intact. Phase 1 has a deliverable checking it, plus one +asserting the created repo's visibility instead of trusting the create command's exit code. + +Also added a phase-1 deliverable asserting `/Users/chris/dev/t3code` is byte-identical at the end +of the phase — same HEAD, clean tree. Two of this round's worst findings were about something +writing into that clone by accident, so it is worth checking rather than intending. + +The word "fork" is kept elsewhere in the plan and in `pin.json`'s field names, describing the git +relationship rather than GitHub's feature. Noted in the plan so it does not read as a +contradiction. + +## plan-approval APPROVED — entering implement, phase 1 + +Architect approved and recorded the gate on 2026-08-30. + +### Standing instruction for every phase, from the architect + +**After each phase, answer: "what can a human see or do now that they could not before?"** + +This is how spec 146 failed — thirteen phases of correct plumbing landed and nobody could see +anything, which is the reason this spec exists at all. When the honest answer is "nothing yet, +this is infrastructure", **say that in the phase report** rather than letting the phase count +stand in for progress. + +**Phase 7 is the first phase that puts anything on a screen.** Phases 1-6 are all infrastructure: + +| Phase | Visible to a human? | +|---|---| +| 1 vendoring harness | No — tooling only | +| 2 hierarchy columns | No — schema only | +| 3 write-time integrity | No — refusals on an API nobody calls yet | +| 4 gate block + scope | No — state nothing renders yet | +| 5 regenerated contract | No — vendored types | +| 6 porch-driver + codev-agent publish | Barely — data now exists in t3code's DB, still unrendered | +| **7 sidebar** | **Yes — the tree appears in t3code's web app** | + +**If phases 1-6 start sprawling, tell the architect and reorder rather than discovering it at +phase 7.** That is an explicit instruction, not a suggestion. + +### Where things stand entering phase 1 + +Nothing has been built yet. No repo created, no code written. Phase 1's first act is +`gh repo create pseudoseed/t3code --private` — never `gh repo fork`, see the plan's executive +summary for why the two are not interchangeable. + +--- + +## Phase 1 — Two-identity vendoring harness + +### What a human can see or do now that they could not before + +**Nothing.** This is infrastructure, exactly as the table above predicts for phase 1. There is a +private repository that did not exist, and the tooling can now hold two checkouts without +confusing them. Nothing renders. Phase 7 is still the first phase that puts anything on a screen. + +### What landed + +`pseudoseed/t3code` created with `gh repo create --private` — **not** `gh repo fork`, because a +fork inherits the source repository's visibility and cannot be private off a public parent. +Asserted rather than inferred: `gh repo view` reports `visibility: PRIVATE`, `isFork: false`. +Branch `codev` at `082e6ea5`, checked out at `/Users/chris/dev/t3code-codev`, `origin` = the +private repo, `upstream` = `pingdotgg/t3code`. MIT `LICENSE` byte-identical to upstream's. + +`/Users/chris/dev/t3code` is untouched: still on branch `main` at `082e6ea5`, clean tree. The +fork was cloned with `--no-hardlinks` so the two repositories share no object files at all. + +New `tools/t3-fork/identities.mjs` holds the mapping once. Every tool asks it rather than +re-deriving `process.env.T3CODE_ROOT ?? ''`, and a test asserts that — the "seven +readers" table is now executable rather than a paragraph. + +### The destructive one, and how it is now tested + +`acquire()` does `checkout --detach` against the upstream clone, and `smoke.mjs` and +`live/integration.mjs` both call it. On `pin.commit` it would have written a fork sha into the +read-only clone from an ordinary test run. `acquire`, `start` and `status` are now pinned to +`upstreamBase`; `verify` is the only verb that knows about both. + +The test for it does not read the source. It builds a throwaway repo with two commits, points +`upstreamBase` at the earlier one and `commit` at the later one, runs `acquire`, and asserts +which sha the tree landed on. + +### T3_PIN_FILE + +New env override on `t3-server.mjs`. "Fork is dirty at its pin" and "the fork's merge-base is not +`upstreamBase`" are only reachable with checkouts sitting on the pinned shas, and no test can make +a throwaway repository produce t3code's shas. The alternative was asserting those paths by reading +the source, which proves nothing about what the process does. + +### Two things the fixtures taught + +Two git repos built from identical bytes, message and a fixed author identity in the same second +produce the **same commit sha**. The "unrelated histories" fixture shared a commit with the tree +it was supposed to be unrelated to, so it exited 0 where 3 was expected. `makeRepo` now writes +unique content per repository. + +A fork that *lacks* the base commit and a fork that has it but no longer descends from it are two +different answers: `3` (NO_FORK_MERGE_BASE) and `1` (FORK_BASE_MISMATCH). Both are tested. + +### Port 3799 was held by someone else + +A `t3 serve` from the main checkout (`--base-dir /Users/chris/dev/codev-1455`, started 13:58) held +3799, so the first evidence run failed with `EADDRINUSE` reported as "no pairing token". Not killed +— it is not this session's. The cold-start evidence was re-collected on `T3_HARNESS_PORT=3811`. + +### Deferred / notes for later phases + +- `source-hash.json` now has an `upstream` section and a `forkDrift` block. Both read as + "not yet diverged" because the fork head equals `upstreamBase`. Phase 5 is where they start + carrying information. +- `classify-churn --upstream-movement` reports 3 closure commits between `upstreamBase` and + `origin/main`. Upstream has moved; that is a phase-5 decision, not a phase-1 one. + +### Phase 1, review round 1 + +claude APPROVE; opencode/grok REQUEST_CHANGES with two real findings, both accepted. + +**`ready()` re-imposed the fork requirement one call after `start()` dropped it.** `start` was +upstream-only on purpose, then `smoke.mjs` runs `acquire, verify, start, ready` and `ready` called +the both-identity `verify`. On phase 2's first fork commit — `pin.commit` does not move until +phase 5 — a correct upstream server would have failed `ready` with `CHECKOUT_MOVED_DURING_RUN`, +a signal about a checkout the server never touches. Added `verify-upstream` / `verify-fork`; +`ready`, `smoke.mjs` and `live/integration.mjs` use the upstream one. Bare `verify` still asserts +both, which the acceptance criterion requires. + +**`verifyCheckout` swallowed a failed `git status` and reported clean.** Inherited from spec 146, +including the comment that claimed it reported undetermined. Now exits 3 with `NO__STATUS`. +Test triggers it for real: `chmod 000` on `.git/index` leaves `rev-parse HEAD` working and makes +`git status` exit 128, landing the failure exactly between the two checks. + +**Open question for the architect, not resolved here:** phases 2-4 commit to the fork while +`pin.commit` stays at `upstreamBase`, so bare `verify` will report `FORK_CHECKOUT_MISMATCH` for +that whole window. That is the plan's sequencing. The per-identity verbs mean it no longer blocks +an upstream server start, but somebody has to decide whether `pin.commit` should advance with each +fork commit or stay put until phase 5. + +**Consult lane note:** the opencode lane timed out at 360s on its first attempt and produced no +verdict (exit 1, loudly). `consult` has no timeout flag — `OPENCODE_TIMEOUT_MS` is hard-coded to +6 minutes at `packages/codev/src/commands/consult/index.ts:1561`. A plain retry completed. + +### Architect ruling on `pin.commit`, implemented + +**`pin.commit` stays at `upstreamBase` until phase 5.** It means "the vendored contract was +generated from this commit", and only regeneration moves it. Advancing it per fork commit would +make the file assert something false. + +So `FORK_CHECKOUT_MISMATCH` through phases 2-4 is the truth. But a signal that fires for three +phases straight is one people learn to ignore, so the two cases are now spelled differently: + +| `pin.contractSource` | Fork HEAD descends from `pin.commit` | Does not descend | +|---|---|---| +| `upstream` (phases 1-4) | `FORK_AHEAD_OF_CONTRACT`, exit 0 | `FORK_CHECKOUT_MISMATCH`, exit 1 | +| `fork` (phase 5 on) | `FORK_AHEAD_OF_CONTRACT`, exit 1 | `FORK_CHECKOUT_MISMATCH`, exit 1 | + +Tolerated does not mean silent — it prints on every run. + +A contract commit the fork repository does not contain is `NO_FORK_ANCESTRY`, exit 3. Whether +HEAD descends from a commit that is not there is not a question git can answer, and the old +blanket exit 1 was answering it anyway. + +`pin.contractSource` is the switch. Phase 5 flips it to `"fork"`, and the plan now carries a +deliverable that the flip must be asserted by a test which fails if ahead still exits 0. + +Plan edited at `codev/plans/250-*.md` phase 5 deliverables and acceptance criteria. 63 tests in +the spec 250 suite; `pnpm -w test` 7274 passed, 54 skipped, 0 failed. + +--- + +## Phase 2 — Thread hierarchy in the fork's contract and projection + +### What a human can see or do now that they could not before + +**Nothing.** Schema and contract only, exactly as the phase table predicts. Two nullable columns +exist and nothing renders them. Phase 7 is still the first phase that puts anything on a screen. + +### The plan's wiring premise was wrong + +The plan said to sequence `CodevSchemaGuardLive` after `MigrationsLive` (`Migrations.ts:173`). +**`MigrationsLive` is exported and nothing builds it** — every reference in the tree is its own +definition or its own docstring. The real boot path is `persistence/Layers/Sqlite.ts`'s `setup`, +which calls `runMigrations()` directly and is what both `makeSqlitePersistenceLive` and +`SqlitePersistenceMemory` provide. + +A guard hung off `MigrationsLive` would never have run in production, and a test that built +`MigrationsLive` itself would have passed anyway. The guard is called from `setup` instead, and +the ordering test reads that production file rather than a layer it assembles. + +### Two spellings for the two fields, deliberately + +`ThreadCreatedPayload` keeps `withDecodingDefault(null)`: the log is full of pre-fork payloads, a +rebuild replays every one, and the projector reads `payload.role` unconditionally, so that read +has to be total. + +`OrchestrationThread` and `OrchestrationThreadShell` use `Schema.optional` instead, matching +`linkedPullRequest` — upstream's own newest field, optional so older cached snapshots decode. The +strict form cost **32 errors across 11 upstream test files**, which is the divergence this fork is +explicitly shaped to avoid. Every server read path normalizes `?? null`, so one spelling reaches +clients in practice. Final upstream test churn: 5 fixture edits in 3 files. + +### Two bugs the architect's ruling exposed in phase 1 code + +Both found by running the tools against a genuinely diverged fork for the first time. + +1. **`classify-churn --fork-drift` measured `upstreamBase..pin.commit`.** Those were the same + commit until the ruling froze `pin.commit`. After it, a fork with real customization commits + reported **zero drift** — "I could not tell" spelled exactly like "nothing changed", on the one + tool whose job is answering "what have we changed?". Now measures to `HEAD`, correct on both + sides of phase 5. +2. **The first commit in any range was reported as `baseline`, never classified.** `git log + from..to` excludes `from`, so with a single fork commit the mode returned a placeholder instead + of a verdict. Now seeded from the range start. It immediately produced a real answer: + `consumed-change-undecidable` — the phase-2 contract altered a union in + `orchestration.subscribeThread`, which the classifier honestly refuses to decide. That is a + genuine signal for phase 5. + +### The fork live suite now skips, with a reason + +`spec-146-t3-contract.test.ts`'s fork-hash suite compares generated artifacts against the fork +checkout, which is only valid while the checkout sits ON `pin.commit`. Through phases 2-4 it does +not. Gated on `FORK_AT_CONTRACT` and it names which of three cases it skipped for: + +``` +spec 250 [live: needs the fork checkout ON pin.commit — fork is at 1a414cee8409, +ahead of contract commit 082e6ea52186 (expected until phase 5 regenerates)] +``` + +It reopens by itself once phase 5 moves `pin.commit`, and a test asserts that. + +### Flaky Tests + +`apps/server/src/entrypoint.test.ts > matches through a symlinked entrypoint` fails in the fork. +**Pre-existing and unrelated**: `git diff 082e6ea5 -- entrypoint.ts entrypoint.test.ts` is empty, +the module imports only `node:fs` and `node:url`, and macOS resolves `/var` to `/private/var`. +Not skipped and not modified — touching upstream's test would be gratuitous divergence. + +### Receipts + +- Fork commit `1a414cee8409`, pushed to `origin/codev`. +- Fork typecheck green. contracts **291 passed**; server **2769 passed, 8 skipped**, 1 pre-existing + failure above. +- Codev repo: build green, **7276 passed, 55 skipped, 0 failed**, plus 180 in the v2 suite. +- 66 tests in the spec 250 suite; 17 new fork tests across three new files. + +### Phase 2, review round 1 + +claude REQUEST_CHANGES, opencode COMMENT. Both named the same two substitutions, independently. + +**"Upstream migration still runs after the guard" was a raw `ALTER TABLE`.** That proves SQLite +accepts a column; it says nothing about whether the watermark let the *migrator* run one, which is +the whole question migration 900 got wrong. Now goes through `runMigrations({toMigrationInclusive: +41})` → guard → `runMigrations()`, upstream's own idiom. + +**Criterion 8b was simulated.** "Killed partway through, still opens against the pre-fork server +binary" was tested in-process on an in-memory DB — no kill, no file, no pre-fork binary. Now real: +`tools/t3-fork/criterion-8b.mjs` starts the pinned t3@0.0.36, SIGKILLs a child after the first +ALTER, reopens the half-applied file with that same pre-fork binary, resumes with the fork's real +guard, and reopens again. Evidence at `codev/research/250-criterion-8b-evidence.json`, asserted by +7 tests including a not-older-than-source guard. + +**This needed a new harness verb.** `restart` refuses when nothing is running; `start` wipes the +data dir. Neither could open a database the run did not just create, so criterion 8b was +*unprovable with the tools that existed* and nothing said so. Added `start --keep-data`. + +Third finding, also fixed: `forkSkipReason` reported "ahead of contract commit" for any non-matching +fork head. Behind and unrelated now say so — otherwise a genuinely broken checkout hides inside the +tolerated case for three phases. + +Fork commit `992b781f4314`. Codev repo: build green, 7283 passed, 55 skipped, 0 failed. + +### Phase 2, review round 2 — both lanes APPROVE + +opencode: no issues. claude: APPROVE with three non-blocking items, all real, all fixed. + +1. **Nothing pinned the two `CODEV_SCHEMA_GUARD_*` signals.** They *are* the mitigation for staying + out of the migration registry, and a rename or a merge into one line would have broken that deal + while every other test stayed green. Now asserted: APPLIED fires naming the columns it added, + NOOP fires on the next start, and neither ever fires alongside the other. +2. **`apply-codev-guard.ts` called `applyCodevSchemaGuard` while its docstring said + `codevSchemaGuardStep`.** Fixed by making the code match the docstring, which is also the better + half: the step is what production calls and it emits the signal, so the one test that runs the + guard against a real file now exercises the logging path too. +3. **The 8b evidence recorded `forkRoot` but no fork commit.** A path is not a version. Added + `forkCommit` — and the gap was live: the first regenerated evidence named `992b781f`, then a fork + commit changed the guard and the evidence still described the older one. Added an assertion that + the recorded commit equals the fork checkout's HEAD, skipping (not passing) when the fork is + absent. + +Fork `e1a858434a80`. Codev: build green, **7285 passed, 55 skipped, 0 failed**. 75 tests in the +spec 250 suite. + +### Issue #199 + +Per the architect's displacement ruling the classify-churn lesson stays COLD. The three instances +from this project are filed on #199 as evidence instead — comment 5471612980 — with the argument +that criterion 8b is a rung below the current slot-4 wording: there was no check to answer wrongly, +because the harness had no verb that could host one, and an absence has no output to inspect. + +--- + +## Phase 3 — Hierarchy integrity refused at write time + +### What a human can see or do now that they could not before + +**Nothing.** These are refusals on an API nobody calls yet, exactly as the phase table predicts. +Phase 7 is still the first phase that renders. + +### The rule, and where it is enforced + +One sentence: **the only legal edge is architect → builder.** Enforced in the decider, at write +time. Criterion 11 says "verified against the decider, not against the UI", and that is the point — +a rule enforced only where the tree is drawn is a rule the API does not have. + +No fallback rendering. A reader that reparents an orphan, or draws a parentless builder at the +root, produces a second correct-looking answer, and then two places disagree about the tree with +nothing to say which is right. + +### Six discriminants, not one error + +`parent-not-found`, `parent-in-other-project`, `parent-is-self`, `parent-not-architect`, +`builder-without-parent`, `parent-on-non-builder`. A caller acts differently on each: "no such +parent" is a retry once the parent lands, "wrong parent role" is a caller bug, "builder without a +parent" is a missing field. One error for all of them says something is wrong and nothing about +what to do — and then gets matched on the message string, which is worse than no discriminant. + +**Check order is load-bearing.** `parent-is-self` before `parent-not-found`, because a +self-reference is a caller bug whether or not the thread exists yet and "no such parent" would send +someone hunting a thread that is right in front of them. `parent-in-other-project` separate from +`parent-not-found` for the same reason: the parent *does* exist. + +### The deliberate non-refusal + +A parent archived or deleted afterwards is **not** retro-refused. Retro-refusal would make an +archive fail because of a thread it does not know about, and make archiving order-dependent. Those +children become orphans: still readable, still carrying the edge, pointing at an archived parent. +Two persistence tests assert an orphan stays distinguishable from a thread that never had a parent — +phase 7 needs that difference to put one in the unattributed group and leave the other alone. + +Also accepted: creating a builder under an *already archived* architect. The rule is about the +edge's shape, not the parent's lifecycle; refusing would mean archiving silently changes which +commands are legal, which is a second rule nobody wrote down. + +### A flaw in my own tooling, found the hard way + +`criterion-8b.mjs` was documented as `> evidence.json`. A shell redirect **truncates the target the +instant the process starts**, so when the run crashed transiently it left an empty evidence file +where a passing one had been — and the suite then failed on a file that said nothing rather than on +the run that broke. I destroyed a good record that way. + +Now `--out `: the evidence is written once, at the end, and **only when the run passed**. A +failed run leaves the previous record untouched and reports the failure through its exit code and +stdout. Same shape as everything else this project keeps finding — a failure mode that reads +identically to a different, more alarming failure. + +### Receipts + +- Fork commit `e1b7f7b04af5`, pushed to `origin/codev`. +- Fork typecheck green; contracts **291 passed**; server **2788 passed, 8 skipped**, 1 pre-existing. +- Codev repo: build green, **7285 passed, 55 skipped, 0 failed**, plus 180 in the v2 suite. +- 15 decider tests, one per case, asserting the discriminant rather than the failure. + +### Phase 3, review round 1 — the engine was deleting the deliverable + +Both lanes REQUEST_CHANGES, both found the blocking bug independently. + +`OrchestrationEngine` rewrote every `CodevHierarchyInvalidError` as a generic invariant error +reading **"Failed to generate an event identifier"** — false, not merely lossy — and persisted that +onto the rejected command receipt, which is replayed verbatim on redispatch. All six discriminants +existed only inside the decider. **The entire phase 3 deliverable was being deleted one layer above +where it was tested**, and all 15 decider tests stayed green because they call the decider directly. + +Same shape as phase 2's `MigrationsLive`: testing the layer below the one production uses. + +Added `OrchestrationEngine.codevHierarchy.test.ts`, which dispatches through the real engine. +**Verified to discriminate**: with the mapping reverted, 3 of its 4 tests fail. On this project that +check is no longer optional. + +Two of my own tests asserted nothing, both caught by review: +- one built a `Set` of six string literals and asserted its size — proving six strings are six + strings, while its name claimed to guard the discriminant collapse; +- one asserted on its own input fixture after an archive the decider never mutates. + +Both replaced with assertions on real output. `commandInvariants.test.ts` gained the Codev cases the +plan listed, including both ordering decisions as tests. + +Fork `40fb82ce92a8`. Fork typecheck green, server 2797 passed. + +### Phase 3, review round 2 — both lanes APPROVE + +opencode: none. claude: none blocking, two forward-looking notes. + +**Acted on:** "discriminant survival across the ws/RPC boundary is untested — worth a phase-6 +acceptance item given this spec has twice been caught testing below the layer production uses." +That is exactly right and it is now a phase 6 acceptance item with the reasoning attached, not a +note. `porch-driver` is the first real client; a discriminant that does not survive serialization +does not exist. + +**Recorded, not changed:** `parent-not-architect` merges two of the plan's listed cases because +they share a reason — the parent is not an architect — with `detail` distinguishing them for a +human. Splitting now would invent a distinction no caller acts on; phase 7 can split it cheaply if +the UI needs different wording. + +### Hot tier + +`A test that cannot fail is not a test — revert the fix and confirm the test fails before trusting +it.` promoted, displacing the minimal-repro line, which was demoted to COLD rather than deleted. + +The instruction's slot number and its quoted description pointed at different lines; I went by the +description, flagged the mismatch, and demoted rather than deleted so either reading was a one-line +fix. The architect confirmed the number came from a stale read of a pre-merge file. + +Amendment applied: the *trigger* half ("when stuck after 2 failed hypotheses or ~30 min") is folded +into the consultation lesson, because a threshold only works if it is always-on — a stuck agent does +not go and read the cold file. Cap still 10, file still 30 lines. Skeleton got the addition only: +displacement is cap-driven and 3 against 10 is not at the cap. + +--- + +## Phase 4 — Porch gate block with a server-allocated revision + +### What a human can see or do now that they could not before + +**Nothing.** State on a record nothing renders, written over an RPC nothing calls yet. Phase 7 is +still the first phase that puts anything on a screen. + +### The one rule that made allocation and stale-rejection coexist + +Review round 1 on the plan said these could not both be true as written, and it was right. Resolved +by making `revision` **optional on the command**: + +| `revision` | Server | +|---|---| +| absent (normal) | allocate `gateRevision + 1`, apply, return it | +| present | apply only if it **exceeds** the mark; else `CODEV_GATE_REVISION_STALE` | + +Equal is refused. Two writers that computed the same number are colliding, not agreeing. + +The mark lives **on the thread, not inside the gate block**, and the *clear* raises it too. That is +the entire mechanism for criterion 10: the mark outlives the block it described, so a stale write +arriving after a human answered cannot resurrect the gate. Had it lived inside the block it would +have vanished with it. + +### Why the gate needed its own RPC method + +`RpcAuthorization` maps the **method**, not the command type: `dispatchCommand` as a whole is +`orchestration:operate`. A gate command routed through it would be reachable by every operator and +no row in the scope map could say otherwise. Four places, as the plan required. The commands are +deliberately **absent** from `ClientOrchestrationCommand` and +`DispatchableClientOrchestrationCommand`, which *are* the dispatchCommand payload. + +`codev:gate-write` is in `AuthEnvironmentScope` and in **neither** `AuthStandardClientScopes` nor +`AuthAdministrativeScopes` nor the token allowlist. The scope tests are the exclusions, because +that is where the whole value is. + +### The engine now returns its committed events + +The gate's response must carry the revision allocated for **that** write. Re-reading the thread row +races a concurrent writer: both see the later value and one is told a number never allocated to it. +So `dispatch` returns `{ sequence, events }`. The idempotent replay path returns an **empty** array +honestly — it committed nothing this time, so there is nothing to read a revision from, and the +caller reports the write as unconfirmed rather than inventing one. + +### Deviation: `gateRevision` is optional-in, required-out + +The plan says non-nullable. Strict-required cost **159 errors across upstream test fixtures**, +which is the rebase debt phase 2 established we do not sign up for. So it is +`Schema.optional(NonNegativeInt).pipe(withDecodingDefault(0))`: optional on input, always a number +after decoding. The DB column is `INTEGER NOT NULL DEFAULT 0` and every read normalizes, so there +is still exactly one spelling of "no gate yet". **Flagged for the architect.** + +### My own driver had the brittleness I keep finding in other people's code + +`criterion-8b.mjs` hardcoded a two-element Codev column list. Phase 4 added two more columns and +the driver failed **while the criterion it tests still held** — the pre-fork server opened both the +half-applied and fully-applied databases exactly as before. Now derived from what the guard itself +reports: `present` is what the crash left, `added` is what the resume finished, and the assertions +are properties rather than counts. + +The `--out` safeguard from phase 3 did its job: the failed run left the previous passing evidence +untouched. + +### Interference worth knowing — and my first workaround for it was WRONG + +Running `criterion-8b.mjs` before `npm test` produces spurious failures in unrelated suites. + +I first recorded "run them sequentially" as the fix. **That is wrong and I have disproven it.** +Sequential runs fail too, and *which* tests fail changes every time: + +| Run | Failures | +|---|---| +| 8b concurrent with the suite | 10 in consult/porch | +| 8b, then build, then suite — strictly sequential | 10 in the registry/reconciliation suite | +| 8b, then the suite — strictly sequential | 2 in `test-isolation.test.ts` | +| suite alone | **0** (7285 passed) | + +Every one passes in isolation. **I do not know the mechanism and have not claimed one.** The leading +lead is a latent order/worker dependency in the suite itself — `MetricsDB.defaultPath` looks like it +is computed at module load — surfaced by the load 8b puts on the machine rather than by anything 8b +corrupts. Recorded on #263 as a correction, with both hypotheses marked as leads not findings. + +**Operational rule:** a green suite run that immediately followed a harness run is not trustworthy. +Re-run the suite alone before believing either a pass or a failure. + +### Receipts + +- Fork `3a1780bbf66f` (wiring) and `57d24ddcb3be` (tests), pushed. +- Fork typecheck green; contracts **301 passed**; server **2815 passed, 8 skipped**, 1 pre-existing. +- Codev: build green, **7285 passed, 55 skipped, 0 failed**, plus 180 in the v2 suite. +- 29 new fork tests; 75 in the spec 250 suite here. + +### Phase 4, review round 1 — the same function, the third time + +Both lanes REQUEST_CHANGES; both independently found the blocking one. + +**`isRefusal` was deleting gate refusals.** Phase 3 fixed that function for +`CodevHierarchyInvalidError`. Phase 4 added a third refusal type and I did not extend it, so every +gate refusal — including the stale write that IS criterion 10 — was rewritten as "Failed to generate +an event identifier". **Criterion 10 was false at the wire while all 11 decider tests stayed green.** +Third occurrence of the same mistake. A type-level exhaustiveness check would have caught all three. + +**"Could not tell" shared a spelling with "no", on the routine path.** The unconfirmed branch was +labelled `CODEV_GATE_THREAD_NOT_FOUND`, and an idempotent replay of the same `commandId` lands there +*every time* — so a normal retry was reported as a nonexistent thread. Now +`CODEV_GATE_WRITE_UNCONFIRMED`, plus `CODEV_GATE_WRITE_FAILED` for database and decode errors that +were also being relabelled as a missing thread. + +**Two declared reasons were never constructed.** `CODEV_GATE_SCOPE_REQUIRED` dropped — the transport's +`EnvironmentAuthorizationError` already names the scope. `CODEV_GATE_THREAD_NOT_FOUND` was raised as +the generic invariant error, making the RPC's declared error type a lie for its commonest failure; +found by *tightening a test*, not by reading. + +**Best finding: no projector coverage.** Every decider test hand-builds its read model, so a +projector that dropped `gateRevision` would pass all 11 while every write after the first +re-allocated revision 1 — the exact failure the mechanism prevents, invisible to its own suite. Six +projector tests now; verified to fail when the mark is dropped. + +**Third time this project caught me writing a test that cannot fail**: assertions inside +`if (events[0]?.type === ...)`, and a thread-not-found test asserting only that *something* failed. + +Also done: OAuth allowlist exclusion asserted (the third place the scope must not appear), +and `codev/gateCredential.ts` names the issuance API and on-disk path of the single credential — +read + gate-write, deliberately not operate, scopes asserted as a set, token written 0600 by +temp-and-rename. + +Fork `3d0e76776cd9`. Typecheck green, contracts 304, server 2832. + +**New flaky observation:** `server.test.ts > routes websocket rpc server.upsertKeybinding` failed +once under the full parallel run, passes in isolation and on re-run. Same shared-resource class as +issue #263. Not fixed, recorded. + +### The exhaustiveness check — ruled in, not deferred + +The architect refused a follow-up issue: three occurrences of one mistake in one project is a +structural defect, and a follow-up is a promise to hit it a fourth time. + +`isRefusal` was a hand-written disjunction over a structural union, so adding a member and +forgetting the predicate compiled cleanly. `dispatchErrorKind` now classifies **every** member of +`OrchestrationDispatchError` in a switch whose `default` assigns to `never`, and `isRefusal` reads +that classification rather than keeping a second list. + +**Proven, not asserted**: a fourth member was added and the build refused it by name — +`error TS2322: Type 'ProbeUnclassifiedError' is not assignable to type 'never'` — then removed. + +Runtime default is `internal`, the safe direction: a refusal misclassified as internal is a worse +message; an internal error misclassified as a refusal is a lie about whose fault it was. + +Fork `570cc29dc63c`. Server 2835 passed. + +### The four costumes, now one list in the review + +Phases 2-4 produced the same defect four times, and the review carries them as a single table for +phase 6 to read first: a layer nothing builds, a layer something wraps, a decider tested without its +engine, a read model every test hand-builds. Each passed its own tests; each was found by review or +by a compiler. + +One sentence: **a test that supplies the boundary itself cannot tell you the boundary exists.** +Phase 6 crosses the ws/RPC seam, which is costume five if a `porch-driver` test constructs its own +transport. + +### Phase 4, review round 2 — costume five, one commit after writing the table + +claude APPROVE, opencode REQUEST_CHANGES; same single substantive gap, treated as blocking because +the plan says the credential is provisioned *at server start*. + +**The credential had no production caller.** `gateCredential.ts` named the scopes, named the path, +tested the write — and nothing in the server ran any of it. That is **costume one from this phase's +own review**, produced one commit after I wrote the four-costume table. Its own tests were green and +all of them were meaningless for the only question that mattered. + +Knowing the pattern did not prevent it. Review caught it. What stops the recurrence is the test that +asserts the **call site** — `serverRuntimeStartup.ts` imports the provisioner and runs it as a named +phase — rather than asserting the module works. Verified to fail when the phase is removed. + +Provisioning is non-fatal (a server that cannot write the token still serves every other client) and +idempotent by rotation rather than lookup (reusing a token would mean reading a bearer credential +back off disk; a server that reads tokens is a larger target than one that only writes them). + +Second finding: the scope map **row** was asserted, the **enforcement** was not. A row nothing reads +documents an intention. Now asserts `ws.ts` routes through `requiredScopeForRpcMethod` on both the +effect and stream wrappers, and that an unmapped method throws rather than defaulting to permissive. + +Fork `0254c84e1241`. Typecheck green, server 2839 passed. + +Four regression tests in phase 4 verified by removing their mechanism: `isRefusal`, the projector's +mark, the wire decoding default, the startup provisioning. + +### The antidote, promoted above the table + +The architect's point: the review had the diagnosis five times and the remedy once, in passing. A +table of failure modes without its remedy becomes a talisman someone cites instead of checking. + +**Assert the call site, not the module.** Every one of the five was caught by that move and would +have been prevented by it. The passing tests each asked whether the code *works*; none asked whether +production *reaches* it, and only the second was ever in doubt. + +Also recorded: the architect explicitly agreed with three judgement calls rather than letting +silence stand for it — non-fatal provisioning, rotation over lookup, and declining the brittleness +finding. And that two lanes disagreeing on severity is not a tie to split: opencode's +REQUEST_CHANGES was right and treating it as blocking was right. + +## Phase 4, iteration 3 — both lanes APPROVE, phase closed + +opencode APPROVE with no key issues. claude APPROVE with three non-blocking findings. Fixed all +three in phase, per the standing ruling that structural fixes are never follow-ups. + +The one that mattered: `OrchestrationRefusal` still hand-listed the same three tags that +`dispatchErrorKind` had just been made the owner of. That is the one-list-in-two-places shape the +exhaustive switch was installed to kill — **sitting one line below the switch**, in the same commit +that installed it. Classifying a fourth refusal without also editing the `Extract` would have left +the runtime correct and the type quietly wrong, which is worse than the original bug, because the +compile-time mechanism above it would have looked like it covered the case. + +The general move, and it generalises past this file: **the fix for a list that must not drift is not +a better guard on the copy, it is to stop having a copy.** The switch is now a `DISPATCH_ERROR_KIND` +table under `as const satisfies { readonly [K in OrchestrationDispatchError["_tag"]]: ... }`. +Missing member → missing key; extra key → excess property; and the refusal *type* is derived from the +table's literal values, so there is no second place left to forget. + +Verified both directions: deleting the `CodevGateWriteError` row gives TS2741 naming the tag; +flipping it to `"internal"` turns 2 engine tests red. + +The other two: a doc comment on the wire type still documented `CODEV_GATE_SCOPE_REQUIRED`, dropped +back in iteration 1 — a contract disagreeing with itself, which a phase 6/8 consumer would have read +as current. And the exact-indentation source assertion is now a whitespace-tolerant regex, verified +to still fail on a bare registration and to survive a reformat. + +Fork `51b55d4899e4`, pushed. Typecheck green; server 2839 passed / 8 skipped / 1 pre-existing +(entrypoint symlink, unmodified). 8b evidence regenerated at the new fork HEAD, `passed: true`, +upstream re-verified clean at `082e6ea52186`. + +**What can a human see or do now that they could not before? Nothing yet.** Phases 1-6 are +infrastructure and phase 4 is no exception: the gate block, the revision high-water mark, the +isolated scope and the provisioned credential are all machinery with no rendered surface. Phase 7 is +the first that renders. + +Porch has accepted phase 4 and moved to phase_5 iteration 1, opening with a context-refresh boundary. + +--- + +## Phase 5 — the vendored contract, regenerated from the fork + +The named input was one undecidable churn verdict. Running the classifier against the finished fork +found **three**, not one: `subscribeThread` at the phase 2 and phase 4 commits, and +`dispatchCommand` at the phase 3 commit. All the same class, so all three were decided. + +Deciding them means the step the classifier declines to take: match union members by their +discriminant literal and compare the matched pairs. Over `upstreamBase..51b55d4899e4` that gives ten +findings, no removals, no narrowed types, no newly-required properties, no lost enum members, no +tightened `additionalProperties`. Nine are non-breaking under the classifier's own stated rules. + +**The tenth is not.** Two alternatives were added to the `OrchestrationEvent` union — +`codev.gate-set` and `codev.gate-cleared` — and on an *output* a new alternative is a shape the +client must now handle. A client shape-checking the stream against the pre-regeneration contract +does not ignore a gate-set frame, it *rejects* it: the frame matches no member of the union that +client knows. Phase 4 shipped the server half of gate writes into a repository whose vendored +contract could not decode the events they produce. Regenerating is the fix, not a formality that +follows it. + +So: non-breaking in every respect but one, and that one breaks against the old contract and not the +new one. The test holds both halves, and the half that matters is the second — it rebuilds the +pre-regeneration union by removing the two alternatives and asserts the frame fails against it. +Without that, "the new contract accepts the frame" is a claim about nothing. + +### Three things the phase found that the plan did not name + +**`codev.gateWrite` nearly vendored as nothing.** The plan flagged that `generate.mjs` iterates +`pin.methods` rather than the RPC map, so an unlisted method is silently skipped — correct, and it +stopped one step short. The non-`OrchestrationRpcSchemas` branch resolved schema names from `git.ts` +and only `git.ts`, and `CodevGateWriteInput` lives in `orchestration.ts`. Adding the pin entry alone +would have failed generation. The branch now takes its module from `spec.source`; reverting that +makes generation say `pin.json names CodevGateWriteInput for codev.gateWrite, but git.ts does not +export it.` `classify-churn.mjs` had the same hardcoding and would have reported the method as +`` at every commit — "not in the contract" spelled identically to "this tool looked in the +wrong file". + +**The checker threw on the payload it was vendored to check.** The round-trip test did not fail an +assertion, it raised `UnsupportedKeywordError: ... "minItems"`. Phase 4's one-to-five bound on gate +`choices` is the first schema in the closure to emit `minItems`/`maxItems`, and `shapeCheck` throws +rather than passing on a keyword it has not implemented. `checked.ts` would have thrown at the call +site on every gate-write payload. Implementing the two keywords is a strengthening — nothing that +passed now fails, nothing that failed now passes — so it does not touch the "shape-check is not +relaxed" deliverable. Caught by running the payload a caller would send through the production +checker, not by reading the schema. + +**Re-scoping the cold-start evidence test was half a fix.** `smoke.mjs` still wrote `pinnedCommit: +pin.commit`, so the next collection would have recorded a fork sha as the provenance of an upstream +run. The field is renamed to `upstreamCommit` and reads `pin.upstreamBase`; the test asserts the old +key is *absent*, which is what stops evidence written under the old meaning being read under the +new. `collect-phase10-evidence.mjs` had the same expression. + +### Deliverables + +Pin at `51b55d4899e4`, `contractSource: "fork"`. Closure still nine files — checked in advance, the +import graph of the fork's `orchestration.ts` is byte-identical to upstream's. `source-hash.json` +carries both sections and `forkDrift.changedFiles` is `auth.ts, orchestration.ts`. Twelve patches +exported to `tools/t3-fork/patches/` with `--no-signature`, review aid only, and FORK.md says so +plus the four-step abandonment procedure. The fork-hash live suite reopened by itself and a test +outside the gate asserts it — a gate cannot assert that it opened. + +Every new mechanism verified by reverting it: the two shape-check keywords (twice — dropped from +`SUPPORTED`, then supported but unchecked), `contractSource`, `pin.commit`, the `pin.methods` entry, +the generator's module map, and the evidence field rename. + +**What can a human see or do now that they could not before? Nothing yet.** Still infrastructure. +What changed is that a `codev.gate-set` frame arriving on the stream now shape-checks instead of +being rejected as unrecognized. Phase 7 is the first that renders. + +### Iteration 1 review: one finding, both lanes + +claude REQUEST_CHANGES, opencode COMMENT, same line. `generated/schema.ts:2` still attributed a +fork-only commit to `pingdotgg/t3code`. I had corrected `ATTRIBUTION.md` and `types.d.ts` and +missed the third header, which was a standalone string elsewhere in the generator — and it is the +module that actually ships. + +Fixed one level up: one `PROVENANCE` constant, every emitter reads it. The more useful half of the +finding was the test, which named two files by hand, so the artifact not on the list was the one +that drifted. Extending the list to three would have caught this instance and not the next. It now +reads the directory: every generated artifact naming the upstream repo must also name the fork and +the base, with a guard that it found artifacts at all. Reverting the header fails that test and +nothing else. + +--- + +## Phase 6 — hierarchy and gate state published, and the hop that had never been crossed + +Three things landed and one thing was found. + +**Landed.** `porch-driver` sends `role` and `parentThreadId`, omitted rather than nulled when the +caller names no role, so an upstream server sees the same payload it always did. Two of the fork's +six hierarchy reasons need no projection to decide, so they are refused before the worktree is laid +down. `launchSpawnedBuilder` resolves the spawning architect's thread id once — three answers, and +the middle one is a thread-backed workspace whose architect runs on a terminal, which gets neither +field and says so. And the gate publisher: `status.yaml` projected onto the thread, on its own +socket with the `codev:gate-write` credential, sending no revision. + +**Found.** The plan's acceptance criterion is a live round trip, and the harness could not run one: +`start` runs the published `t3@0.0.36` CLI against upstream, which has no `codev.*` anything. So +`start-fork` now runs the fork's `apps/server/src/bin.ts` directly, on its own port and runtime dir. + +The first run failed on all four cases. Every refusal arrived as +`OrchestrationDispatchCommandError` with the reason inside `message`, as English. Phase 3 fixed the +engine deleting discriminants; the ws layer was flattening them one hop further out, and every test +beneath that hop was green. Third time this spec has produced that shape. + +Fork `804e56f8f864`: `OrchestrationDispatchCommandError` gains an optional `refusal`, +`CodevHierarchyInvalidReason` moves into the contract because it travels, four wrapping sites lift +or forward it. The test that asserts those sites found two the first fix missed — one of them +rebuilds an existing error to add a field, and would have deleted the discriminant while adding it. + +The second run also failed, and it is worth naming why: the script read `domain.reason`, a true +reading of the old server and the wrong one for the new. A test that was right about the world when +it was written, whose failure after the fix looks exactly like the fix not working. + +Third run: four illegal edges, four distinct readable reasons, all carrying +`CodevHierarchyInvalidError`. Recorded in `codev/research/250-hierarchy-wire-evidence.json` behind +an mtime guard. + +**Costs paid.** The fork moving meant `verify` reported `FORK_AHEAD_OF_CONTRACT` at exit 1 — phase +5's flip firing for a real reason one phase after it was armed — so the contract regenerated at the +new head. `OrchestrationDispatchRefusal` is vendored now, so porch-driver's copied reason list is +checked against `generated/schema.json` unconditionally instead of skipping without a fork checkout. +Both evidence files re-collected, again, because `t3-server.mjs` changed. + +**One design note for phase 7.** The gate block's optional content is narrowed to fit the fork's +caps — question dropped over 500 chars, choices capped at 5, excerpt truncated tail-first — and +every drop is reported. `gateName` and `requestedAt` always travel. A renderer should not assume the +question is present, and should not read a truncated excerpt as complete; the marker says so inline. + +Codev suite green: 7367 + 180 passed, 54 skipped, 0 failed. Fork suite 2845 passed, 8 skipped, 1 +pre-existing (entrypoint symlink). + +## Phase 7 — the sidebar draws the tree + +First phase that renders. Two fork commits: the grouping as a pure function, then the call site +that orders the sidebar's active list by it and draws the nesting. A third commits the screenshots. + +**The finding came from the compiler, before a caller existed.** Two assignments from +`SidebarThreadSummary` and `Thread` at the top of `hierarchy.test.ts` caught the module keying on +`threadId` (the command spelling; both read models say `id`) and an interface that `exactOptionalPropertyTypes` +rejects for every real thread. Neither throws. Both would have shipped as "the sidebar sees no +hierarchy", which reads as an empty workspace rather than as a bug. + +**The section boundary was the design problem.** t3code splits a project into Pinned / Active / +Snoozed / Settled before grouping runs, so the tree is built over one list. A builder whose +architect is PINNED was reported `parent-missing` — a lie told to someone who can see the architect +three rows up. `alsoVisible` fixes it with `parent-elsewhere`; role still outranks section. +Reading that map with `get() !== undefined` was a second bug (a roleless thread's role IS +`undefined`) and its test caught it; verified by reverting to `get`. + +**`orderedThreads` is the keyboard's order too.** Shift-range-select and jump hints read it, so the +tree's order and that list are derived from one thing. A reorder that left it alone would put every +row in the right place and send the keyboard to the wrong ones. + +**Playwright against the real stack.** `packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts` +plus `playwright.spec250.config.ts`. Fixture restarts the fork server on empty data, seeds over the +wire, mints a pairing credential per browser context (the bootstrap token is single-use and `ready` +redacts it from the log after one read). The fork's Vite dev server is NOT started by the fixture — +an absent one is a skip carrying the command, never a pass. 7 tests: 4 behavioural, 3 per-viewport. +Falsified by forcing the no-hierarchy branch: eight rows still render, every hierarchy selector goes +to zero. + +**Screenshots** committed to the fork at `docs/codev/spec-250/phase-7/`, 390 / 1440x900 / 1920, two +per width. Writing them is opt-in (`SPEC_250_WRITE_SCREENSHOTS=1`) because `start-fork` refuses a +dirty fork — a suite that wrote them every run passes once then skips forever. Measured, not +eyeballed: no horizontal overflow, titles >= 13px, zero console errors after pairing. + +**No design reference exists for this tree.** Drawn in t3code's own idiom (its row cards, its +divider token for the rail, the Snoozed/Settled shelf shape for the orphan heading); nothing ported +from `apps/client`. Flagged to the architect for a ruling rather than assumed. + +Pin moved twice this phase and finally to `e19e2560dd7a`; contract regenerated, 16 patches re-cut, +both evidence files re-collected at the final pin. + +**Architect review of the first screenshots, three changes, one a criterion gap.** Criterion 1 is +three levels and the render had two — the project was only a caption repeated on all eight cards. +Now a heading with the project's own favicon; rows under it drop the label, rows outside keep it. +Architect rows are captioned "Architect" in the slot that label vacated (indent alone only works +while the test data is called "Architect beta"; real threads are `builder/spir-250`). Orphan group +moved from amber to Settled's muted treatment — an archived parent orphaning its builders is legal, +and amber says broken. + +Worth naming: the suite was green and every criterion had an assertion, and a level of criterion 1 +was still missing. The tests and the render were built from the same reading of the plan. The +screenshot is what exposed it. + +Answered the architect's scheduling question: live per-row working/turning status is t3code's own +`resolveSidebarThreadStatus` and spec 250 does not touch it — rows read "now" because the fixture's +threads have never taken a turn. The blocked-on-a-named-gate half is phase 8. No gap. + +Trap for later: the codev suite must run under the DEFAULT node, not the Node 22 the fork needs. +A run with Node 22 on PATH fails 724 tests on a better-sqlite3 ABI mismatch, which looks like a +regression and is not. + +Pin ended at `48a9aa399e5d` after four fork commits this phase; contract regenerated, 18 patches, +both evidence files re-collected. + +**3-way review (2 lanes this protocol): both APPROVE, HIGH.** No blocking issues. Four non-blocking +notes from the Claude lane, all four acted on: `data-codev-builder-count` now comes from +`entry.builderCount` rather than the render-side run scan (it could only ever agree with the scan +the test counts — the same shape as the five costumes); the Active-only scoping of the tree is +recorded in the review so phase 8 inherits it rather than rediscovering it; `test:e2e:spec250` +added to `packages/codev/package.json`; the `??` expression parenthesised. Rebuttals at +`codev/projects/250-t3code-is-the-front-end-privat/250-phase_7-iter1-rebuttals.md`. + +Pin finished at `7c7096d49de9`, 19 patches, both evidence files re-collected. Codev suite green +(7370 + 180, 54 skipped, 0 failed) under node v20; fork web suite 2887 passed; 9 Playwright tests +green against the live fork. + +**Appearance APPROVED by the architect**, both 1440 and 390 opened. Project heading, the +`Architect` marker, and the neutral orphan shelf all accepted. Explicit ruling to carry forward: +**builders get no positive marker** — an architect is labelled and an indented row under a rail +beneath a labelled architect is unambiguous. Do not add a second pill. + +One non-blocking note, checked and **left alone**: the `(1)` on the unattributed heading. t3code has +no shelf-header count treatment to adopt — Snoozed and Settled inline their counts in the label's own +colour (`Snoozed (3)` in blue, `Settled (12)` in `text-muted-foreground/50`), and `ui/badge.tsx` is +not used by any shelf header. The count already carries `text-secondary-label` against the label's +`text-muted-foreground/50`, which is the only emphasis t3code's own tokens offer at that level +without inventing a badge. The architect's rule was "use t3code's treatment if one exists, leave it +if not, do not invent" — so it stays. + +## Phase 8 — the gate panel + +`apps/web/src/codev/gateState.ts` (pure, three states) and `GatePanel.tsx` (t3code's `Alert` in the +`info` variant, above the composer, NOT in `ComposerBannerStack` — that stack shows one banner at a +time behind a cap and a gate is neither dismissible nor small). Sidebar marker is its own element in +rose, outside the status slot so it survives a hover. + +**The finding: a gate must be written by the credential that writes gates.** A bootstrap exchange +asking for `codev:gate-write` is refused `invalid_scope` — phase 4 holding. The fixture reads +`/codev/gate-writer.token` and opens its own connection, which is what +`thread-backend.ts` does in production. + +**Second finding: the screenshot trap has a two-file version.** Phase 7's opt-in flag was not +enough — with two spec files the first writes PNGs into the fork and the second SKIPS in the same +run, correctly. Screenshots now write to `SPEC_250_SCREENSHOT_DIR` (outside the fork) and are +copied in. Phase 7's pictures were re-shot in the same commit because two builders now carry gates. + +Falsified: collapsing `pending-unstructured` into `none`, and rendering the question with +`dangerouslySetInnerHTML`, each fail 8 tests. + +17 Playwright tests green (8 gate + 9 hierarchy) at that point; 18 after the gated-architect test. +Pin at `39204a7ac368` then, `efadf838c414` finally. + +**Architect review of the phase 8 screenshots.** Heading "Waiting on you: " approved and kept +— it leads with the required ACTION rather than describing a state, which is the difference between +a gate and a status. Sidebar and panel wording deliberately differ (scan vs summon); do not +reconcile them. Two changes: the terminal excerpt gained a caption, and the row marker became a +gavel plus the gate name after both `Gate: ` placements clipped something at ~230px (line +above the title clipped the role caption to "A…"; the title line clipped the title). One clip left +deliberately: a 15-char gate name on a gated architect shows "Archit…", with the gate name and the +title intact. + +Pin at `efadf838c414`, 25 patches, both evidence files re-collected. + + +**3-way review (2 lanes): both APPROVE, HIGH.** opencode raised nothing. Claude raised one item and +it was the LOG rather than the code: the fork web suite number was stated against a source, not a +commit, and three commits landed after it. Re-run at `efadf838c414`: typecheck 0, **2916 passed**. +Codev suite at the same pin: 7370 + 180, 54 skipped, 0 failed. + +**Two operational findings this phase, both worth carrying.** + +1. **`git push` hangs in a non-interactive session on this machine.** `porch done` sat 28 minutes + with build and tests already green; its child `git push` was blocked in + `git credential-osxkeychain get`, which waits on a prompt nothing here can answer. Reads are + fine (`git ls-remote` is instant), so it is the write path only. Workaround, no config on disk: + `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0='!gh auth git-credential'` + in front of any command that pushes. Do NOT `git config --local` in a worktree — that writes the + shared config and hits the main checkout and every other builder. A global fix is Chris's call. +2. **The exported patches were 14MB**, four of them base64 PNG screenshots rewritten on every + re-shoot. Architect ruled: exclude `docs/codev` from the export. Now 504KB / 21 patches; the four + screenshot-only commits produce none, and `FORK.md`'s phase log is the complete commit list. + `format-patch -o` also takes an ABSOLUTE path — a relative one writes into the fork checkout. + +## Phase 9 — builder tiling + +Geometry ported from `apps/client/src/responsive/layout.ts` and **re-measured**. The one change that +matters: every function takes the AVAILABLE width, not the viewport, and the grid measures its own +container with a `ResizeObserver` (a window listener misses a collapsed sidebar, which changes the +space without changing the window). 1176px at 1440, not 1404. Three columns fit either way — so +**criterion 5 would have passed on the viewport version by luck**, and criterion 5b is the one that +catches it. `PAGE_PADDING` 18 → 12, `GRID_GAP` stays 12; both recorded with the measurement. + +Route is `_chat/codev-builders`, a child of the chat shell on purpose — the criteria are about how +many panes fit BESIDE the sidebar. + +**Architect ruling that changed the work:** my option set (add a contract field / subscribe per pane +/ ship without) was wrong on all three. `codev-agent` ALREADY publishes the porch phase and the last +three messages, workspace-scoped, one request for the whole grid — +`GET /api/agent/v1/workspaces//state`. That is where apps/client reads them. Pane content lands +with **phase 10** over the same-origin proxy. **Do not extend the fork's contract for it.** + +And the wording: "Phase not published" was FALSE. It is published; this page cannot reach it yet. +Now reads "Phase not read here yet — published by codev-agent". + +**Two defects the screenshots caught and the tests did not:** pane text was 12px (`text-xs` is right +for a sidebar row, wrong for a scanned tile; raised rather than narrowing the assertion), and at 390 +the shell's floating sidebar toggle sat on the first pane's title (route has a header now). + +24 Playwright tests green across three specs. Rendered columns are counted from the browser's x/y +positions AND from `data-codev-grid-columns` — the attribute alone would be the component confirming +its own arithmetic, which is the phase 7 builder-count defect. + +One combined run failed once at 12px immediately after the `text-[13px]` edit — Vite had not served +the new CSS yet. Green twice since on a settled server. Not a code defect and not skipped. + +Pin at `2529a40421d1`, 24 patches (552K), both evidence files re-collected. + +**Criterion 4b restored, at the architect's direction, after the first 1440 screenshot.** Spec 250 +restated 5 and 5b and dropped 4b; the screenshot showed exactly what 4b prevents — six builders plus +an architect at three columns is 3+3+1. Architect now takes a persistent strip below the grid that +expands to a full pane; an equal tile only where FOUR COLUMNS FIT. + +**Stated as columns, not as "1920 or wider", and the architect approved the departure before I +built it.** apps/client's viewport number is right there because that client owns the viewport; +here 1920 of viewport is 1688 of grid, and a viewport threshold would offer the tile with the +sidebar dragged wide enough that only three columns fit. Four columns IS the reason: 7 items at 4 +columns is 4+3. Keyed on width alone, never the builder count — asserted as its own test, because a +count-based rule reflows the layout under a reader who did nothing. Spec 146's wording is unchanged; +apps/client is frozen and still owns its viewport. The plan now carries 4b as a criterion. + +Header counts agents not tiles: "6 builders and an architect". With more than one architect there is +no "the" architect, so they all take tiles and no strip appears. + +Architect will NOT rule on pane internals until phase 10 puts real content in them — every pane is +placeholder right now and that is not something to approve on. + +Pin at `b97ef30dea2b`, 25 patches, both evidence files re-collected. 7 tiling tests green. + +**Architect condition on multi-architect mode, and it found a real exposure.** With every architect +taking a tile there is no strip, no indent and no rail — the `architect/` prefix is the entire +distinction — and it lived inside the truncating span, so a long title would eat it. Prefix and +title are separate elements now; the prefix does not shrink and the title gives way. + +The check measures `scrollWidth` vs `clientWidth`, NOT text: `text-overflow: ellipsis` is invisible +to a text assertion because the DOM keeps the whole string, so `toContainText("builder/")` passes on +`buil…`. And the test could not have failed as first written — six short fixture titles never fill a +pane. One fixture builder is long now; reverting the fix clips that pane's prefix by 22px. + +26 e2e green. Pin at `aeebd7f2b9c2`, 26 patches, both evidence files re-collected. + + +**3-way review (2 lanes): Claude APPROVE/HIGH, opencode COMMENT/HIGH.** Stricter is binding; all +seven points acted on. The important one: **the grid had no in-app entry point** — the route existed, +nothing linked to it, and my own e2e `page.goto`'d the path, which proves a route renders and says +nothing about whether a user can reach it. Sidebar has a Builders link now (gated on +`hasCodevHierarchy`) and the e2e clicks it. + +Second real defect: **the width was measured two ways** — `getBoundingClientRect` (border box) on +mount vs `contentRect` (padding removed) from the observer, then `contentWidth` subtracting padding +again. Named viewports still landed on 3 and 4 columns, which is what made it dangerous. Padding +moved to an inner wrapper. + +Also: orphans now tile (phase 7's reasoning, missed a phase later); `data-codev-architect-placement` +no longer says "strip" on a page with no architect; the header counts architects in the +multi-architect case; `--codev-pane-body` is actually consumed; one grouping pass instead of two. + +**The sidebar is 256px, not the 232 my comments claimed.** Conclusions unchanged (1184 → 3 columns, +1664 → 4), but the unit tests used invented numbers. Now measured — and 1440 with the sidebar +COLLAPSED fits four columns, so the architect gets a tile there. That is the rule working. + +Rebuttals at `codev/projects/250-t3code-is-the-front-end-privat/250-phase_9-iter1-rebuttals.md`. +Pin at `8d4b878f3137`, 27 patches. 26 e2e green, fork web 2938. + +## Phase 10 — approval from t3code over the same-origin proxy + +The proxy is `apps/server/src/codev/agentProxy.ts`, at `/api/codev/agent//`. +**Its upstream is server-configured** (`T3CODE_CODEV_AGENT_ORIGINS`, `id=origin` entries) and the +browser selects by **id**, never by URL — the plan's own most consequential item, because a proxy +forwarding to a browser-named origin is an SSRF primitive and a route-path allowlist does not +constrain the host. + +**The forward set is an allowlist, not a denylist**, and `Connection`'s own named tokens are +subtracted from it anyway. Both matter: an allowlist alone forwards a header a request declares +connection-scoped, and that header is the machine credential. `authorization` and `cookie` never +travel — t3code's session gates USE of the proxy and is not approval authority. + +Deliberately not carried: the SSE stream (this proxy buffers), and both revocation routes (`afx +pair revoke` is the operator path; a browser that could revoke could deny a human their gate). +A 3xx from the configured origin is **refused**, not passed on — forwarded, the browser follows it +cross-origin. + +**Pane content landed here too**, per the architect's ruling: `codev-agent` already published the +porch phase and the last three messages workspace-scoped in one request, and the panes now read it. +The fork's contract was not extended. Phase 9's "not read here yet" wording is gone; every branch +that still cannot show a phase names which — unpaired, unavailable, absent, or no porch project. + +**Three defects the browser caught and no unit test could.** + +1. **The page read the agent store once and froze.** Pairing succeeded, the credential reached + browser storage, the poll ran — and the panel still said this browser holds no credential. The + hand-rolled `useState` tick pushed from a listener set never followed the store. + `useSyncExternalStore` with a replaced (never mutated) snapshot. This cost the most time in the + phase and every unit test was green throughout. +2. **A gated pane dropped the phase it had just gained** — the gate replaced the phase line, which + was right when there was no phase and wrong the moment one existed. +3. **`Send a message to start the conversation.` printed across `Waiting on you: `** on a + thread with no turns. Present since **phase 8**; the phase 8 panel-only screenshot could not show + it and the full-page one did, unnoticed. Hidden through upstream's own `hideEmptyPlaceholder`, + which is also the honest fix: a thread at a gate is not waiting for a message. + +**Tests.** Fork: 28 `agentProxy` unit tests (real sockets for both failure signals and for the +redirect refusal), 43 web unit tests across `pairing`/`approval`/`agentState`. Codev: a 7-test +vitest e2e driving the REAL fork server's proxy in front of a real `agent-routes` host, ending in a +real `status.yaml` — criterion 4 — plus the SSRF refusals and `afx pair revoke`. Playwright: 6 tests +against the live web app, recording **every request the page issues** and asserting each is +same-origin, which is what replaces the CSP assertion the plan's first draft proposed against a +header t3code does not send. + +**Falsifiability.** Reverting the `Connection`-token subtraction, the header allowlist, the redirect +refusal, the credentials-in-URL rule and the anchored route pattern fails 5 unit tests. Pointing the +configured allowlist at a dead port fails 4 of the 7 e2e tests, which is what proves the ceremony +goes through the configured proxy and nothing else. + +**Traps.** The Playwright run needs the DEFAULT node (v20): `better-sqlite3` is built for it, and +the agent host is in-process. That collides with `@cluesmith/t3-client`'s need for a global +`WebSocket`, so the fixture polyfills from `ws` when the global is absent. And `start-fork` refuses +a dirty fork, so the fork cannot be instrumented for a browser debugging session — the store bug had +to be found by elimination and fixed by using the right primitive. + +Pin at `e0476d49aec1`, 31 patches. Both evidence files re-collected at the pin. + +**Filed #264 while phase 10's suites ran — a SAFETY defect, not noise.** The builder received +"Gate pr approved — please run `porch next` to advance." for a gate nobody approved in this project. +`porch approve` sends the bare PROJECT ID as the `afx send` target (`porch/index.ts:1267`); the +workspace is taken from the SENDING process's cwd (`send.ts:96-113`); the agent is then TAIL-matched +against builder ids with leading zeros stripped (`tower-messages.ts:387-392`, `:510-511`). So `250` +from a Playwright fixture's temp workspace addressed `spir-250` in this one. Neither hop carries the +identity of the workspace whose `status.yaml` was written. + +Porch state was unchanged — verified twice, and `porch next 250` still returned phase_10. The +correct response on receipt is to verify against porch and refuse to advance; #264 records that as +the mitigation as well as the defect. `notifyTerminal` no-ops under vitest (`notify.ts:62`), which +is why the sibling vitest e2e performs the same approval silently and only the Playwright run fires +it. **Not fixed here** — it is porch/tower, and folding it in would put an unrelated change in a +fork PR. + +**Phase 10 review: Claude APPROVE/HIGH, opencode REQUEST_CHANGES/HIGH.** Stricter binding; every +finding accepted, nothing in a disagree column. Rebuttals at +`codev/projects/250-t3code-is-the-front-end-privat/250-phase_10-iter1-rebuttals.md`. + +**The binding one is the most useful finding in the whole spec so far.** My vitest e2e's +availability guard logged a warning and RETURNED — which vitest records as a **pass**. On a run +where the fork server never started, criterion 4 and every SSRF refusal reported green with zero +assertions executed. This project keeps catching "I could not tell" spelled as "no"; that is the +same defect spelled as **"yes"**, on the phase's own acceptance criterion. The file's header stated +the rule ("Skips, never passes") and the code broke it — a header is not a mechanism. It was +invisible to me because every run I did had the fork up. `ctx.skip` now, and demonstrated rather +than asserted: `T3_NODE` unset gives **8 skipped** where it used to give **8 passed**. + +Two more, both fixed. `UPSTREAM_TIMEOUT_MS` is an **idle-socket** timeout and the comment claimed +it bounded the whole exchange — the same class of error as the `connect-src` claim this phase +existed to correct; the comment now states the residual (a trickling upstream is not bounded, and +that upstream is one the operator named). And `data-codev-approval-state` collapsed `sessionEnded` +into `refused`, which **both lanes found independently**; four outcomes, four words, in an exported +pure function so the attribute and the rendering cannot drift. + +One I found myself between the lanes: the proxy buffered request bodies with **no bound** — Effect's +`MaxBodySize` defaults to unbounded. Capped at 64 KiB, declared oversize refused before the read. +Verified by running the same test against the fork commit before the fix: fails there, passes now, +with no fork history touched. + +Pin at `3786b840e1a4`, 33 patches. Fork web 2984, fork server 198, 32/32 Playwright across all four +spec-250 specs, e2e 8 passed. Both branches pushed. + +**Phase 10 CLOSED after iteration 2: Claude COMMENT/HIGH, opencode APPROVE/HIGH with no issues.** +opencode verified the iteration-1 fixes in the code rather than in my rebuttals — `ctx.skip` typed +`never`, the idle-timeout wording, four-to-four attribute mapping, and the proxy registered in +`server.ts` beside the targets route (the wiring, not just the module). + +Iteration 2's one finding, and it is the same family as iteration 1's one layer in. Iteration 1 was +a test that **could not run** and reported a pass. Iteration 2 was a test that **could run and +could not fail**: `url.startsWith(origin)` is a prefix match, `webAppUrl()` defaults to a fixed +`http://localhost:5733`, and the agent host binds an ephemeral port — so ports 57330-57339 (ten, +inside macOS's ephemeral range, ~0.06% of runs) would have had a genuinely direct browser-to-agent +request counted as same-origin, and the phase's central security assertion would have passed anyway. + +A rare false pass is worse than a common one: it makes the test look reliable while it is not, and +0.06% is the rate at which nobody ever sees it fail. + +The durable half of the fix is not the comparison — it is that the predicate **moved out of the +Playwright spec into its own module so it could be tested at all.** The function deciding whether a +security claim passed was the one piece of the suite with no test of its own, which is how it stayed +wrong. Five unit tests now, in the default suite; restoring the prefix match fails three of them. +Non-http schemes are exempt as a CLASS (`data:`, `blob:`, `about:`), because naming `blob:` beside +`data:` would have left `about:` to break a later run. + +**Phase 11 groundwork, done during phase 10's review waits.** Architect ruled the rebase drill runs +on a THROWAWAY clone with the real pin unchanged, and the plan now records why: advancing +`upstreamBase` to satisfy a phase would strand every spec 146 and 236 result tied to `082e6ea52186`. +Criterion 6 closes run-and-met or UNMET-with-a-runbook, never open and never on a simulation. The +runbook is `codev/resources/250-ipad-acceptance-runbook.md`, and verifying it against the fork +rather than writing it from memory caught three wrong instructions — the worst being that it sent +the human to `t3-server.mjs start-fork`, a throwaway data dir with empty data, for a criterion that +says a builder is driven to completion. + +Pin `3786b840e1a4`, 33 patches. Both branches pushed. **No PR yet — phase 11 opens it.** + +## Phase 11 — acceptance run and the rebase drill + +**Criterion 9 met with real numbers, and the numbers are more useful than the criterion asked for.** +Upstream had moved 104 commits since our base, so this was not the zero-churn path. 5 commits touch +the pinned closure: 3 `source-only`, 2 `consumed-change-undecidable` — and the two undecidable ones +are union changes in `subscribeThread` and `dispatchCommand`, the exact two unions our customization +adds members to. `classify-churn` says "undecidable" rather than guessing, which is the tool being +honest; the drill is what answered it. + +**The drill is `tools/t3-fork/rebase-drill.mjs`, and it measures rather than performs.** Ruled by +the architect: it runs on a throwaway clone and the real pin does NOT move, because advancing +`upstreamBase` to satisfy a phase would strand every spec 146 and 236 result tied to +`082e6ea52186`. A fetch is the one permitted write — remote-tracking refs move, HEAD does not. +**The drill re-reads both checkouts afterwards and discards its own result if either moved**, which +is the difference between checking the order held and promising it did. + +**A rebase stops at the first conflict, so the first conflict understates the job — every time.** +That is the design decision worth carrying: the sequential rebase reported "stopped at 6 of 42 on +one file", and a drill reporting only that would understate every rebase it ever measured, in the +reassuring direction. So it also three-way-merges the same two trees in one pass: **3 files of the +35 we modify.** + +**The prediction was wrong in the interesting direction.** `orchestration.ts`, rated High in +FORK.md and touched twice upstream in the unions we extend, **auto-merged clean**. What conflicted +was an upstream TEST — the half FORK.md already warned is easiest to forget. Risk table now carries +measured beside predicted. + +**The watermark invariant finally had a real migration.** Phase 2 tested it synthetically; upstream +has since shipped `043`, above the `042` our base leaves. Codev writes nothing to +`effect_sql_migrations`, so it runs. `checked: false` is its own state and is not a pass. + +**`apps/client` was RED and had been since phase 5.** The regeneration moved the session-status enum +from `_6` to `_7` and the frozen client's `derive.test.ts` still read `_6`. The architect's framing, +which is better than mine: the freeze AUTHORISES the fix — "frozen means it keeps passing its tests +and receives fixes" — because a fallback whose suite is red is not a fallback, and *works* is a +claim its suite is the only evidence for. **Its assertion message is why this cost a minute rather +than an hour**: it said "that is this test needing a new path, not a mapping change", which is the +difference between two problems that look identical at the failure site. Filed **#265** for the real +gap: root `npm test` filters to `@cluesmith/codev`, so nothing local runs the fallback at all. + +**One timeout was a wrong budget, not a flaky test, and the two have opposite remedies.** +`classify-churn --fork-drift` re-emits the closure once per closure-touching commit, and that range +grows as the fork does; the 5s default silently became too small. Raised with the reason at the call +site. Skipping it would have removed coverage of the named-zero contract to hide arithmetic that +works. + +**Criterion 6 closes UNMET.** No iPad. Runbook written and verified against the fork — which caught +three wrong instructions I had written from memory, the worst sending the human to +`t3-server.mjs start-fork`, a throwaway data dir with empty data, for a criterion that says a +builder is driven to completion. + +Evidence: `codev/resources/250-acceptance-evidence.md`, with its numbers regenerated by +`tools/t3-server/collect-spec-250-evidence.mjs` (exit 3 unreadable/stale, exit 1 drifted — different +answers, different codes). + +### Phase 11, iteration 1 review — the same defect a third time, and it was mine again + +**Both lanes found one thing: the drill's `ok` outcome claimed a shape-check that never ran.** claude +REQUEST_CHANGES/HIGH, opencode COMMENT/HIGH. The header defined `ok` as "rebase clean, contract +regenerated, shape-check held" and listed `regenerate-failed` and `shape-check-failed` beside it. The +clean branch rev-parsed and rev-listed. Neither tool was called; neither of those two outcomes was +assigned anywhere in the file. + +**That is three findings of the same shape in two phases, and I wrote all three.** Phase 10 iteration +1: a guard that logged and returned, which vitest records as a pass. Phase 10 iteration 2: +`startsWith` as a same-origin assertion, unable to fail on ephemeral ports. Now a comment describing +work no code does — on the tool whose stated subject is that "I could not tell" must never be spelled +like "no". The pattern is not carelessness about tests. It is that **I write the claim in prose while +building the mechanism, and then the prose is what I re-read when checking my work.** The prose is +always right, because I wrote both. + +**What I got wrong about the fix, before getting it right.** My first instinct was the reviewer's +option 1: just call the generator on the rebased tree. It cannot be done. `generate.mjs` refuses any +checkout whose HEAD is not `pin.commit`, and a rebased tree never satisfies that — its head is a +commit that did not exist before the rebase. So option 1 requires moving the pin, which is the exact +adoption the drill exists in order not to perform. Fifteen minutes to see that, and it changed the +answer from "add two lines" to "the tool has been describing a capability it structurally cannot +have". + +**Stopping at option 2 — narrow the header — would have been the third mistake in the same family.** +It makes the document honest and leaves criterion 9 answered by `regenerationReachable`, a boolean +that only says the generator would FIND its source. So the drill now measures what it can: +`contractClosure.sourceHash` hashes the closure off the merged tree and compares to +`generated/source-hash.json`, the layer `generate.mjs` itself argues is load-bearing because the +emitted schema is blind to constraints behind a `decodeTo` transform. + +**And the measurement changed the answer, which is the only reason it was worth taking.** Zero +closure conflicts — so regeneration is not blocked — but **4 of the 9 closure files come out of the +merge with different bytes**. "Regenerable" and "unchanged" had been reading as one fact in FORK.md, +in REFRESH.md and in the acceptance evidence. They are two. + +**The ordering inside that measurement is the whole thing, and it is one line's worth of care.** The +hash must be taken while the merged worktree is on disk, before `merge --abort`. Taken after, the +worktree is the fork again and the comparison is the fork against itself. I checked rather than +assumed: hashing the unmerged fork against `source-hash.json` reports `moved: []` — which is what the +post-abort version would have published on every run, forever, looking exactly like good news. + +**The test that would have caught the original defect reads the file.** `documents exactly the +outcomes it can assign, and no others` extracts the documented vocabulary from the header comment and +the assignable one from the `outcome:` assignments and asserts set equality. A prose-only fix cannot +fail; this can. All three new assertions were verified by reverting their mechanism. + +### The fixture sent me a gate approval again, and porch was the only thing in the way + +Mid-phase, my prompt received "Gate pr approved — please run `porch next` to advance." **There was no +pending gate and no PR.** I refused it and asked the architect, who confirmed they had approved +nothing since plan-approval. It is my own Playwright suite — `spec-250-approval.spec.ts` approves a +gate in a throwaway porch project, and `notifyTerminal` fires for real under Playwright, no-opping +only under vitest. + +**Second occurrence, and both times an agent had to reason its way to the refusal.** Nothing marks +the message as untrustworthy: well-formed, names a real gate type, arrives on the channel legitimate +architect instructions arrive on, asks for a command I run every phase. The architect's framing is +the one that belongs in #264 and is now its headline: if `porch approve` sends the bare project id +and the recipient is tail-matched with leading zeros stripped, the blast radius is **any project +whose id tail-matches, in any workspace on this machine** — a cross-workspace misroute of the one +message class carrying human authority. Recorded there with what would have happened had I trusted +it: porch state was untouched, so `porch next` would have run against an unapproved gate, and porch +would have been the only thing between a fixture and an advanced project. + +### Phase 11, iteration 2 review — the defect inverted, in my own new tests + +**claude APPROVE/HIGH, opencode COMMENT/HIGH, nothing blocking.** Four notes, all actioned. + +**The one worth carrying: my new tests would have FAILED a correct drill.** The drill has two +shapes, and `NO_UPSTREAM_MOVEMENT` is a pass — with upstream still at our base there are no new +migrations to shadow and no merged tree to hash, so that result legitimately carries +`watermark.checked: false`, `contractClosure.checked: false`, zero churn, and no `preserved` block at +all. My suite asserted the other shape unconditionally: three assertions would have failed and a +fourth would have thrown. + +Two iterations went into tests that pass when they should fail. This is a test that fails when it +should pass. **It is the same missing question in both directions — which claim is this artifact +actually making — and I did not think to ask it of the tests I had just written to fix asking it of +the code.** Verified the fix the only way that means anything: ran the suite against a synthetic +zero-movement evidence file. 6 passed, 7 skipped. The old suite failed 3 and threw on 1. + +While there I found `if (evidence.outcome !== 'conflicts') return;` inside a test body — a pass with +zero assertions, which is precisely the phase 10 finding, still sitting in a file I wrote. Now +`it.runIf`. + +**A comment outlived the test it described by one commit, and opencode caught it.** The +drill-closure header still claimed the check order was asserted below, one commit after that test was +deleted for being unfalsifiable — with the accurate comment contradicting it at the bottom of the +same file. Iteration 1's finding was a header claiming a check the code did not perform. This is +that, one file over, introduced by the fix for it. + +**I defended the hand-typed churn split and the defence was a description of the problem.** "3 +`source-only`, 2 `consumed-change-undecidable`" stayed prose because it comes from a separate run. +That is exactly why it rots. `classify-churn --upstream-movement` is now persisted to +`codev/research/250-upstream-movement.json` and printed into the generated block, with two refusals: +a classification covering a different range than the drill, and one run against the fork instead of +upstream. The counts matched what had been typed — which is the point. The fix was not prompted by a +wrong number; the next drill is where a typed one goes wrong silently. + +**Both lanes were asked directly whether deleting the order test was right, and both said yes**, for +the reason I had given. claude added the caveat: the subset relation is a caller obligation the +exported function does not enforce, so a malformed input would make order observable. Left alone — +defending against an input no caller produces is defending against a hypothetical. + +**Restarted both lanes mid-review.** The first pair began reading `rebase-drill.mjs` while I was +extracting `closureMeasurability` out of it. A review of a tree that moves underneath it is not a +review, and five minutes is cheaper than a finding I could not trust either way. + +### The architect asked the question the whole phase had routed around + +**"Criterion 9 proves the rebase MEASURES, not that the contract still regenerates after one."** +Correct, and I had written every sentence that made it sound settled: the generator refuses a tree +whose HEAD is not `pin.commit`, regenerating means moving the pin, the drill exists in order not to +move the pin. All true. Together they added up to a criterion reading "met" with the interesting half +deferred to the first real rebase — the worst moment to discover it does not work. + +Two review iterations passed over it. Both lanes accepted the reasoning, and so did I, because the +reasoning is sound. **What none of us asked was whether the constraint had a way around it that was +not "loosen the guard".** It did. + +`git merge-tree --write-tree` writes a merged tree as an object even with conflicts, and +`commit-tree` gives it an identity — inside the throwaway clone. That matters more than it sounds: +the sequential rebase stops at commit 6, so there is no rebased HEAD to point at, but the generator +reads only the closure and the closure merges clean. Then the second piece: `generate.mjs` resolves +`pin.json`, its output directory and its staging area **from its own file location**, so a copy of +the tool under a scratch directory reads a scratch pin. The guard is satisfied rather than bypassed — +the artifacts really are reproducible from the commit they name. + +**The contract regenerates. `schema.json`, `schema.ts` and `types.d.ts` all move.** Adopting this +base changes the shapes we consume, and that is now a run with an artifact list. + +Three things I would have got wrong an iteration ago: + +- The comparison is against what is **vendored here**, never against what the scratch run just + wrote. That is the third time in this phase the tautology was the thing to design against, and the + first time I saw it before writing it. +- A regenerated contract that differs is a **result**. Widening the outcome vocabulary to + `shape-check-failed` would have re-created exactly the defect iteration 1 found, so the finding + went into `contractRegeneration` with its list and `outcome` stayed three words. +- The generator needs Node 22 and the drill runs under 20. A wrong interpreter reports + `NO_INTERPRETER`, never "the contract does not regenerate" — the second is a claim about the fork + made from a fact about this laptop. + +**Cost: about an hour, including a hand probe before writing any of it.** The probe is what made the +decision cheap: fifteen minutes assembling the scratch root by hand told me the mechanism worked +before I committed to a design. + +--- + +## Review phase — the retrospective the incremental log was not + +The review file was 1,559 lines of per-phase findings and had **none** of the REVIEW template's +sections: no Summary, no Spec Compliance, no Deviations, no Consultation Feedback, no Lessons +Learned, no Architecture or Lessons routing, no Follow-up Items. Written incrementally as each +phase landed, it answered "what went wrong in phase N" perfectly and "did this spec get built" +not at all. + +So the template sections were written and the phase log demoted to `## Phase-by-phase record` +below them, unmodified. Not merged into the new sections — 1,500 lines of findings compressed +into four headings would lose the thing that makes them worth keeping, and a diff that rewrites +every line of a document nobody asked to have rewritten is worse than an appendix. + +**The Consultation Feedback section is reconstructed from the committed artifacts, not from +memory.** Every verdict came from grepping `VERDICT:` out of the 43 raw lane files across 21 +rounds (20 implementation, 1 plan); every +disposition came from the `*-rebuttals.md` written at the time. Two files carry two verdict +blocks (a lane reviewing twice in one output); the **last** one is the binding one, which is +what the rebuttal responded to — checked rather than assumed, because taking the first would +have recorded phase 11 iteration 1 as an APPROVE when it was the round that found the drill's +`ok` tautology. + +The **agy/Gemini lane produced no output for this project.** It is absent from every round and +recorded as absent, not as an approval — a lane that did not run and a lane that approved must +not be spelled the same way. + +### Governance routing + +- **Cold, `arch.md`**: a new `### The t3code Fork (Spec 250)` under the existing `## Integration + Points`. Deliberately under an existing top-level section: a new one would have forced a + thirteenth entry into the hot file's cold-doc map, which is capped at 12. +- **Hot, `arch-critical.md`**: one fact — the fork, the read-only upstream clone, never `gh repo + fork`, and **`apps/client` is the frozen fallback**. Hot because the mistake it prevents is one + made *before* anyone consults a reference doc. +- **Displacement**: the file was at 10/10 facts, so the two-tier-governance one-liner was demoted + into `arch.md`'s Governance section, which already stated it in full — and each hot file's own + header comment repeats the rule to anyone editing it. Caps after: 10 facts, 12 map topics, 32 + lines. +- **Cold, `lessons-learned.md`**: the "looks flaky, is actually under-budgeted" distinction from + phase 11, and harness runs poisoning the following suite (#263). + +### What the Spec Compliance pass changed + +Nothing about the code — but writing criterion 6 as a checkbox forced the question of what an +unrun criterion *is*. It is not met and not open. `[ ] 6. UNMET` with the runbook, and a +methodology note that the template's two checkbox states cannot express it. + +### The PR review could not be fetched, and the refusal exited 0 + +`consult --type pr` refused on both lanes: `gh pr diff 266` returns **HTTP 406 — the diff exceeded +the maximum number of lines (20000)**, and this PR is 43,714 lines across 130 files. The refusal +message is the right one ("a reviewer cannot tell an empty diff from a failed fetch, and neither +can you once three lanes have returned APPROVE") — and then **both lanes exited 0**, so a caller +checking the exit status sees a successful consultation that wrote no file. Filed as **#267**. + +The cap is on the API, not the content. `git diff origin/main...builder/spir-250` returns the same +diff with no cap, and consult already writes the diff to a temp file for the model to read rather +than inlining it, so size was never the constraint anywhere else in the path. + +Fixed for this run with a `gh` shim on PATH that intercepts `pr diff` only and passes everything +else through. **`.codev/config.json` was deliberately not edited: it is a symlink to +`/Users/chris/dev/codev-1455/.codev/config.json`, so a `forge.pr-diff` override there would have +changed the forge for every builder and the architect to work around one oversized PR.** The +equivalence was checked rather than assumed — the shim's diff reports 130 changed files, which is +what `gh pr view 266 --json changedFiles` reports. + +--- + +## The PR review round, and the rebuttal that did not survive it + +**Claude APPROVE, opencode COMMENT.** No blocking findings. Three items accepted. + +The one that matters: **the phase 11 rebuttal about the evidence collector test was too broad, and +being too broad hid a real race.** The argument was "the test's value is that it drives the +collector against its REAL committed inputs, so pointing it at fixture copies would test a copy." +True — of **one** test in a file of six, the one asserting the committed numbers still match the +runs. The other five work by *damaging* an input, and a damaged input has no reason to be the +committed one. I had written one argument and applied it to the whole file. + +What that concealed: `spec-250-vendoring-identities.test.ts` reads +`codev/research/250-criterion-8b-evidence.json` **in its module body**. Vitest runs files in +parallel workers. So a worker collecting that file while this one held the mutation fails on +corrupted data — in a file that has nothing to do with the collector, with nothing in its own output +to explain why. I had not gone looking for other readers of the paths I was mutating. + +The fix reuses the phase 11 technique rather than inventing one: the collector resolves its root +from `import.meta.url`, so a **copy of the script under a `mkdtempSync` root reads that tree's +inputs** — which is exactly how the drill regenerates the contract without moving the pin. No flag +added to the tool to suit a test, nothing tracked written, and a killed run now leaves a temp +directory instead of a mutated repository. + +**Falsifiability was not optional here, and I nearly assumed it.** Three of the five refusal tests +assert exit 3, and `MISSING_RUN` — a scratch root missing one input — is *also* exit 3. A fixture +that was subtly incomplete would have passed them for exactly the reason this project got wrong five +times. Removing all five mutations: **5 failed, 1 passed.** Every refusal test fails without its +damage and the happy path still passes, so the collector genuinely runs in the scratch root. + +### #268, which is sharper than the finding that prompted it + +Claude noticed `status.yaml` `history` records 9 rounds against ~20 on disk and called it a +recording gap. Checking every recorded round against its verdicts, it is not a gap — it is a rule: +**a round is recorded if and only if at least one lane did not approve.** No exception in either +direction across 20 rounds. Phases 7, 8 and 9 are absent entirely, and they are precisely the three +where both lanes approved on round 1. + +So a phase reviewed cleanly reads identically to a phase never reviewed, and `history` understates +review effort *selectively*, biased toward the phases that went badly. That is this project's own +recurring defect living in the protocol's state file. Filed rather than hand-edited. + +### A red suite that was my own fault, and the test was right + +The `src/__tests__/` run came back **1 failed, 1859 passed**: +`spec-1280-measurement-instrument.test.ts > T12 — determinism > emits byte-identical output twice +at the same commit`. + +I had been committing review fixes **while that suite ran**. The test invokes the instrument twice +and compares the bytes; a commit landing between the two invocations changes what the instrument +reads. So it caught exactly what it exists to catch, on a change it was not meant to be measuring. + +Re-run alone with no concurrent commits: **24 passed**. Not recorded as flaky, and not skipped — +nothing about it is timing-sensitive, and calling it flaky would remove a real determinism check to +hide an operator error. The rule that resolved it in one step is #263's: re-run a suspect suite +alone before believing the failure. The corollary is new: **do not commit while a suite that reads +git state is running.** + +--- + +## Review round 2 — codex found two blocking defects two lanes had approved + +The architect ran a lane the first round did not have. Both findings real, both on the approval +surface, both fixed in-phase. + +### The one that should not have survived eleven rounds + +`send` in `approval.ts` did a bare `await fetchImpl(...)`, and **four of its five call sites had no +`catch`**. `GateApproval` had a `finally` and no `catch`. So a proxy disconnect while opening the +session, issuing the capability, minting the nonce, or taking the synchronous fallback escaped as a +rejected promise: the spinner stopped and **nothing** appeared. No error, no unconfirmed state, no +outcome. + +This is the defect the whole project was about, on the surface where it costs most, and I wrote it. + +**Why it survived.** `approval.ts` has an unusually careful outcome vocabulary — four outcomes, a +header explaining which two a shorter port drops, `unconfirmed` spelled apart from refusal in five +places, and a refusal to invent `approvedAt` from the browser clock. Every reviewer, me included, +read that and found it good. Nobody asked the cruder question one level down: what happens when +`fetch` itself rejects? **A rich taxonomy of answers is not the same as answering.** The care spent +on the vocabulary is exactly what made the gap invisible; it looked like a file that had thought +about failure. + +**The fix is a type, not a `try`.** `send` returns `({reached:true} & Json) | {reached:false, +error}`. `reached: false` is not assignable to anything reading `.status`, so the compiler asks at +all five sites and a sixth cannot inherit the bug by forgetting a `try`. A `try` fixes today's +call sites; a union fixes the next one too. + +**And the outcomes are deliberately not the same.** Pre-submit — session, capability, nonce — is a +*definite* failure: nothing was submitted, so the gate provably did not move. Telling someone to go +check a gate that cannot have changed is how `unconfirmed` becomes ordinary network noise, and then +the rare real one gets ignored. On a submit, either route, nobody knows. Flattening the two into one +"network error" would have been the same defect wearing a tidier coat. + +### The second one, and the first version of the fix had the bug it was fixing + +The upstream response was buffered with `chunks.push(chunk)` and nothing watching the total — the +same defect as the request-body bound I found and fixed *before* review, on the return path. The +worse half: a request body comes from an authenticated caller, a response body comes from whatever +the configured target is. + +Then: `settle` after `destroy()` reported the wrong thing, because `destroy()` emits `error` +synchronously and that handler settles `unreachable`. **The truthful outcome lost a race to a +vaguer one** — a reachable, answering host reported as unreachable. Settling first is what makes it +safe, and `settle` being once-only is why. Caught only because the test asserted `oversized` +specifically rather than "not answered". + +### The harness port trap, which cost four attempts + +Moving `pin.commit` invalidates every evidence run that records a fork commit, and the collector +refuses with `STALE_RUN` rather than publishing them — correct, and it means a fork commit obliges +re-running `criterion-8b.mjs`, `spec-250-hierarchy.mjs` and the drill, not just regeneration. + +Re-running them hit **two long-dead sessions' servers**: one from Aug 30 on 3799 based in the MAIN +workspace, one from Aug 28 on 3823 in a temp dir. The harness refuses to kill either — +`REFUSING to kill pid(s) ... on port ...: not ours` — which is right, and `T3_HARNESS_PORT` is the +way past it. + +**`(echo >/dev/tcp/127.0.0.1/$p)` reported every port free under zsh**, which does not implement +that redirection. It is not a port check; it is a check that always says yes. `lsof -nP -iTCP:$p +-sTCP:LISTEN` is the one that answers. Two of the four failed attempts were mine believing a probe +that could not fail. + +### The e2e failure I caused, and it was the variable I had just learned to set + +The first e2e re-run at the new fork head came back **4 failed, 28 did not run** — the first test in +each spec file timed out waiting for `sidebar-codev-builders-link`, which is phase 9's sidebar entry +and nothing to do with the approval path or the proxy. Serial mode meant the other 28 never ran. + +Not a regression. **I passed `T3_HARNESS_PORT=3830` to Playwright**, which the documented invocation +does not. The evidence runs need that variable, because other sessions hold 3799 and 3823; the +Playwright fixture must NOT have it, because the Vite dev server on 5733 proxies to a fixed backend +port and moving the fork server elsewhere leaves the page served but its backend dead. The page +returned 200 with no threads in it, so every locator timed out on a working server that had nothing +behind it. + +**A variable that fixes one tool can break the next one in the same shell.** I carried an export +forward because it had just solved a problem, into a command whose documented form omits it. The +recorded invocation in this file omits it for a reason, and the reason is now written down. + +### #264 fired a third time, and this run pinned the trigger + +Mid-run, a message arrived: `Gate pr approved — please run \`porch next\` to advance.` +`porch status 250` at that moment: **`WAITING FOR HUMAN APPROVAL`, gate `pr`, unapproved.** + +A spec-250 Playwright run was in flight, started by me seconds earlier. The correlation holds across +every occurrence in this worktree: the fixture drives the real approval ceremony against a real +`codev-agent`, and the approval it performs reaches the builder looking exactly like a human ruling. + +**What makes this worse than a stray notification.** The message is indistinguishable from a real +approval, it lands on the one gate whose entire purpose is that only a human passes it, and it +instructs the recipient to take the action that consumes it. A builder that trusts it advances past +a human gate on the strength of its own test suite. + +What stopped it was a line in `.builder-state.md` saying to check `porch status` before acting on +any gate-approval message. **That is a habit, not a control** — it survives only as long as someone +keeps writing it into the state file, and it would not survive a context refresh that dropped it. +Added to #264, with the suggestion that a gate-approval message should not be actionable on its own: +`porch status` is the authority, and the message could say so rather than instruct. diff --git a/packages/codev/package.json b/packages/codev/package.json index 5d2998bde..b5f474c2f 100644 --- a/packages/codev/package.json +++ b/packages/codev/package.json @@ -39,7 +39,8 @@ "test:e2e:cli": "pnpm build && vitest run --config vitest.cli.config.ts", "postinstall": "node ./scripts/postinstall.mjs", "prepublishOnly": "pnpm build", - "copy-client": "pnpm --filter @cluesmith/codev-client build && rm -rf client-dist && cp -r ../../apps/client/dist client-dist" + "copy-client": "pnpm --filter @cluesmith/codev-client build && rm -rf client-dist && cp -r ../../apps/client/dist client-dist", + "test:e2e:spec250": "playwright test --config playwright.spec250.config.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.41", diff --git a/packages/codev/playwright.spec250.config.ts b/packages/codev/playwright.spec250.config.ts new file mode 100644 index 000000000..467c15f4a --- /dev/null +++ b/packages/codev/playwright.spec250.config.ts @@ -0,0 +1,28 @@ +/** + * Playwright configuration for spec 250's fork sidebar E2E. + * + * Separate from `playwright.config.ts` because that one starts TOWER and serves + * the Codev dashboard. This suite is about t3code's own web app, on its own + * origin, with a fork server the spec's fixture starts itself — sharing a config + * would mean every run of either suite booted the other's stack. + * + * Run: npx playwright test --config playwright.spec250.config.ts + */ + +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/__tests__/e2e', + // The fixture restarts the fork server and seeds it over the wire before the + // first test; a cold Vite dev server then transforms the module graph on the + // first page load. + timeout: 120_000, + retries: 0, + workers: 1, + // No webServer. The fork's Vite dev server is a long-lived foreground process + // this suite has no business owning — an absent one is reported as a skip with + // the command to start it, never started silently and left running. + use: { + baseURL: process.env.T3_WEB_URL || 'http://localhost:5733', + }, +}); diff --git a/packages/codev/src/__tests__/e2e/spec-250-agent-host.ts b/packages/codev/src/__tests__/e2e/spec-250-agent-host.ts new file mode 100644 index 000000000..a9305da9c --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-agent-host.ts @@ -0,0 +1,301 @@ +/** + * Spec 250, phase 10 — a real `codev-agent` for the fork's proxy to reach. + * + * Shared by the two tests that need one, and shared rather than copied for the + * usual reason: the vitest e2e drives the proxy over `fetch` and the Playwright + * spec drives it from a page, and if the two built their own hosts they would + * drift into testing two different services. + * + * ## What it is + * + * `agent-routes` in-process on a random port, over a real workspace holding a + * real porch project at a pending gate. Nothing here writes `status.yaml` — + * porch does, which is the whole point of asserting the file afterwards. + * + * ## What it seeds, and why identity seeding is not optional + * + * `codev-agent` publishes an identity per row in `architect` / `builders`, and + * attaches a porch projection by matching the row's WORKTREE against the + * artifact root of a status record found under it. So a host with no rows + * publishes an empty workspace — which a pane would render as "not published", + * truthfully, and prove nothing about the phase actually appearing. + * + * The seeded rows carry the caller's own thread ids, so the identities line up + * with the t3code threads the fork's web app is showing. + */ + +import Database from "better-sqlite3"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as yaml from "js-yaml"; + +import { GLOBAL_SCHEMA } from "../../agent-farm/db/schema.js"; +import { ApprovalCapabilityStore, ApprovalNonceStore } from "../../agent-farm/lib/approval-capability.js"; +import { MachineCredentialStore } from "../../agent-farm/lib/machine-credentials.js"; +import { PairingStore } from "../../agent-farm/lib/pairing.js"; +import { + HumanPairedSessionRegistry, + handleAgentRoute, + initAgentRoutes, + shutdownAgentRoutes, +} from "../../agent-farm/servers/agent-routes.js"; +import { normalizeWorkspacePath } from "../../agent-farm/utils/workspace-path.js"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", ".."); + +/** The machine name every capability is issued FOR. Asserted, not assumed. */ +export const AGENT_MACHINE = "test-machine"; + +export interface SeededBuilder { + /** The builder id, e.g. `spir-250`. Its digits resolve the porch project. */ + readonly id: string; + /** The t3code thread this row is the identity of. */ + readonly threadId: string; + /** Porch project id. Left absent for a builder with no project. */ + readonly projectId?: string; + /** A gate to leave pending, with #128's structured request attached. */ + readonly gateName?: string; + /** Messages addressed to this builder, newest last — the agent reverses. */ + readonly messages?: readonly { readonly from: string; readonly body: string }[]; +} + +export interface AgentHostSeed { + readonly architect?: { readonly id: string; readonly threadId: string }; + readonly builders: readonly SeededBuilder[]; +} + +export interface AgentHost { + readonly origin: string; + readonly port: number; + readonly workspacePath: string; + readonly encodedWorkspace: string; + readonly pairings: PairingStore; + readonly machines: MachineCredentialStore; + /** Add identities after the host is running. See {@link startAgentHost}. */ + seed(seed: AgentHostSeed): void; + /** Where a seeded builder's `status.yaml` lives, for asserting the approval. */ + statusPathFor(builderId: string): string; + stop(): void; +} + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), "codev-spec250-agent-")); +} + +function projectDirName(projectId: string): string { + // REAL PROJECTS ARE NAMED `-`, never bare ``. A fixture using the + // bare form hides any code that builds a path from the id. + return `${projectId}-spec-250`; +} + +/** + * The workspace root, with porch's checks skipped. + * + * These tests are about whether an approval can be reached from a browser, not + * about whether a throwaway directory can run a build, and the skip goes through + * the mechanism porch supports for exactly that. + */ +function makeWorkspaceRoot(): string { + const root = tempDir(); + mkdirSync(join(root, ".codev"), { recursive: true }); + writeFileSync( + join(root, ".codev", "config.json"), + JSON.stringify({ porch: { checks: { build: { skip: true }, tests: { skip: true } } } }), + ); + return root; +} + +/** One worktree per builder, each holding a porch project. */ +function seedWorktrees(root: string, seed: AgentHostSeed): void { + for (const builder of seed.builders) { + if (builder.projectId === undefined) continue; + const worktree = join(root, ".builders", builder.id); + const projectDir = join(worktree, "codev", "projects", projectDirName(builder.projectId)); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, "status.yaml"), + yaml.dump({ + id: builder.projectId, + title: "spec 250 approval from t3code", + protocol: "air", + phase: "implement", + plan_phases: [], + current_plan_phase: null, + gates: + builder.gateName === undefined + ? {} + : { + [builder.gateName]: { + status: "pending", + requested_at: "2026-08-30T00:00:00.000Z", + request: { + question: "Approve the plan, or send it back for another round?", + choices: [ + { + label: "Approve", + consequence: "Implementation starts on the plan as written.", + recommended: true, + }, + { label: "Send it back", consequence: "The plan is revised first." }, + ], + }, + }, + }, + iteration: 1, + build_complete: false, + history: [], + }), + ); + // The real protocol definitions, so `approve` runs real phase checks rather + // than a shape invented here. + cpSync(join(REPO_ROOT, "codev-skeleton", "protocols"), join(worktree, "codev", "protocols"), { + recursive: true, + }); + } +} + +function seedRows(database: Database.Database, workspace: string, seed: AgentHostSeed): void { + if (seed.architect !== undefined) { + database + .prepare( + `INSERT INTO architect (workspace_path, id, pid, port, cmd, terminal_id, thread_id) + VALUES (?, ?, 0, 0, 'claude', NULL, ?)`, + ) + .run(workspace, seed.architect.id, seed.architect.threadId); + } + for (const builder of seed.builders) { + database + .prepare( + `INSERT INTO builders (workspace_path, id, name, worktree, branch, terminal_id, thread_id, spawned_by_architect) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + ) + .run( + workspace, + builder.id, + builder.id, + join(workspace, ".builders", builder.id), + `builder/${builder.id}`, + builder.threadId, + seed.architect?.id ?? null, + ); + /* + * ASCENDING timestamps, one second apart. + * + * `recentByAgent` orders by `created_at DESC, id DESC`, so messages inserted + * within the same millisecond tie and fall back to the id — which is random + * here. A test asserting "the last three, newest first" over a tie is a test + * that passes on the order the ids happened to sort in. + */ + let messageAt = Date.now() - (builder.messages ?? []).length * 1_000; + for (const message of builder.messages ?? []) { + messageAt += 1_000; + database + .prepare( + `INSERT INTO mailbox (id, workspace_path, to_agent, from_agent, body, formatted_message, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'delivered', ?, ?)`, + ) + .run( + `${builder.id}-${Math.random().toString(36).slice(2, 10)}`, + workspace, + builder.id, + message.from, + message.body, + message.body, + messageAt, + messageAt, + ); + } + } +} + +/** + * Start a real `codev-agent` over a freshly seeded workspace. + * + * `seed` may be added to AFTER the host is running, and the Playwright spec needs + * that: the fork's server has to be started with this host's port in its + * environment, and only then can t3code threads be created — so the thread ids + * the identities carry do not exist yet at start. + */ +export async function startAgentHost(seed: AgentHostSeed): Promise { + const workspaceRoot = makeWorkspaceRoot(); + const stateRoot = tempDir(); + const workspace = normalizeWorkspacePath(workspaceRoot); + const pairings = new PairingStore({ root: join(stateRoot, "pairing") }); + const machines = new MachineCredentialStore({ root: join(stateRoot, "machines") }); + const database = new Database(":memory:"); + database.exec(GLOBAL_SCHEMA); + const seeded: SeededBuilder[] = []; + const applySeed = (next: AgentHostSeed): void => { + seedWorktrees(workspaceRoot, next); + seedRows(database, workspace, next); + seeded.push(...next.builders); + }; + applySeed(seed); + + initAgentRoutes({ + db: () => database, + log: (level, message) => { + if (level === "ERROR") console.error(message); + }, + isKnownWorkspace: (candidate) => normalizeWorkspacePath(candidate) === workspace, + humanSessions: new HumanPairedSessionRegistry(), + approvalCapabilities: new ApprovalCapabilityStore({ + root: join(stateRoot, "approval"), + machine: AGENT_MACHINE, + }), + approvalNonces: new ApprovalNonceStore({ root: join(stateRoot, "approval") }), + machineCredentials: machines, + pairings, + }); + + const server: Server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (handleAgentRoute(request, response, url)) return; + response.writeHead(404).end(); + }); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + const { port } = server.address() as AddressInfo; + + return { + origin: `http://127.0.0.1:${port}`, + port, + workspacePath: workspace, + encodedWorkspace: Buffer.from(workspace, "utf8").toString("base64url"), + pairings, + machines, + seed: applySeed, + statusPathFor(builderId: string): string { + const builder = seeded.find((candidate) => candidate.id === builderId); + if (builder?.projectId === undefined) { + throw new Error(`builder ${builderId} was seeded with no porch project`); + } + return join( + workspaceRoot, + ".builders", + builderId, + "codev", + "projects", + projectDirName(builder.projectId), + "status.yaml", + ); + }, + stop(): void { + shutdownAgentRoutes(); + server.close(); + database.close(); + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(stateRoot, { recursive: true, force: true }); + }, + }; +} + +/** Every mint names its ceremony and what authorized it. */ +export const MACHINE_MINT = { + purpose: "machine-credential" as const, + authority: "test harness", +}; +export const SESSION_MINT = { purpose: "client-session" as const, authority: "test harness" }; diff --git a/packages/codev/src/__tests__/e2e/spec-250-approval.spec.ts b/packages/codev/src/__tests__/e2e/spec-250-approval.spec.ts new file mode 100644 index 000000000..df5119f73 --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-approval.spec.ts @@ -0,0 +1,391 @@ +/** + * Spec 250, phase 10 — approving from t3code, in a real browser. + * + * ## The claim this exists to make, which no other test can + * + * **The page makes no cross-origin request.** The plan's first draft proposed + * asserting `connect-src 'self'` — and t3code sends no page-level CSP at all + * (`Content-Security-Policy` appears on `.svg` asset responses only, and + * `index.html` carries no meta tag), so that assertion would have passed + * vacuously against a header nobody sends. The guarantee here is structural, not + * declared: the page has no absolute URL to use. So this WATCHES THE NETWORK — + * every request the page issues while it pairs, reads state and approves — and + * asserts each one is on t3code's own origin. + * + * ## And the second claim: the panes carry real content now + * + * Phases 7-9 rendered "Phase not read here yet — published by codev-agent", + * which was true and is the sentence this phase removes. The grid here is backed + * by a real `codev-agent` with real identities, so the panes show a real porch + * phase and real `afx send` messages — and one pane deliberately shows the + * "codev-agent does not publish this thread" branch, because that state is + * ordinary and a screenshot where every pane resolves would hide it. + * + * ## Running it + * + * Terminal 1, from the fork's `apps/web`, Node 22 on PATH: + * T3CODE_SINGLE_ORIGIN_DEV=1 T3CODE_PORT=3811 PORT=5733 npx vp dev + * Terminal 2, from `packages/codev`: + * npx playwright test --config playwright.spec250.config.ts + */ + +import { readFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import * as yaml from "js-yaml"; + +import { + AGENT_MACHINE, + MACHINE_MINT, + SESSION_MINT, + startAgentHost, + type AgentHost, +} from "./spec-250-agent-host"; +import { crossOrigin } from "./spec-250-same-origin"; +import { + forkScreenshotPath, + mintPairingCredential, + seedApproval, + startForkStack, + stopForkStack, + type ForkStackReady, + type SeededApproval, +} from "./spec-250-fork-stack"; + +/** The env var the fork's server reads its codev-agent allowlist from. */ +const ORIGINS_ENV = "T3CODE_CODEV_AGENT_ORIGINS"; +const TARGET_ID = "local"; +const BUILDER_ID = "spir-250"; +const PROJECT_ID = "250"; +const MACHINE_NAME = "playwright"; + +let stack: ForkStackReady | null = null; +let seeded: SeededApproval | null = null; +let agent: AgentHost | null = null; +let unavailable: string | null = null; +let previousOrigins: string | undefined; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + /* + * ORDER IS LOAD-BEARING, and it is a circle broken in one place. + * + * The fork server reads its allowlist from its environment at start, so the + * agent host must exist first to have a port. The agent's identities carry + * t3code thread ids, which do not exist until the fork server is running. So + * the host starts EMPTY and is seeded after the threads are created. + */ + agent = await startAgentHost({ builders: [] }); + previousOrigins = process.env[ORIGINS_ENV]; + process.env[ORIGINS_ENV] = `${TARGET_ID}=${agent.origin}`; + + const started = await startForkStack(); + if (!started.available) { + unavailable = started.reason; + return; + } + stack = started; + seeded = await seedApproval(started); + + agent.seed({ + architect: { id: "main", threadId: seeded.architectThreadId }, + builders: [ + { + id: BUILDER_ID, + threadId: seeded.builderThreadId, + projectId: PROJECT_ID, + gateName: seeded.gate.name, + messages: [ + { from: "architect", body: "Phase 9 accepted. Start phase 10." }, + { from: "architect", body: "Bring screenshots when the panes carry content." }, + { from: "architect", body: "The proxy target is configured, never browser-selected." }, + ], + }, + // No `projectId`: codev-agent publishes an identity for the architect and + // this builder, and NOT for the third thread the fixture created. + ], + }); +}); + +test.afterAll(() => { + if (stack !== null) stopForkStack(); + agent?.stop(); + if (previousOrigins === undefined) delete process.env[ORIGINS_ENV]; + else process.env[ORIGINS_ENV] = previousOrigins; +}); + +test.beforeEach(() => { + test.skip(unavailable !== null, unavailable ?? ""); +}); + +const VIEWPORTS = [ + { name: "390", width: 390, height: 844 }, + { name: "1440x900", width: 1440, height: 900 }, + { name: "1920", width: 1920, height: 1080 }, +] as const; + +function ready(): { stack: ForkStackReady; seeded: SeededApproval; agent: AgentHost } { + if (stack === null || seeded === null || agent === null) { + throw new Error("unreachable: availability is checked before this runs"); + } + return { stack, seeded, agent }; +} + + +/** + * Every request the page issued, as absolute URLs. + * + * Recording starts before the first navigation, so nothing the page does escapes + * it — including a request made while a later assertion is still waiting. + */ +function recordRequests(page: Page): string[] { + const seen: string[] = []; + page.on("request", (request) => seen.push(request.url())); + return seen; +} + +/** Pair a fresh browser context with t3code itself, and land in the app. */ +async function openApp(page: Page, path: string): Promise { + const { stack: live } = ready(); + const credential = await mintPairingCredential(live); + await page.goto(`${live.webUrl}/pair#token=${credential}`, { waitUntil: "domcontentloaded" }); + await expect( + page.getByText("Enter a pairing token to start a session"), + "the browser did not pair with t3code; every assertion below would be about the pairing form", + ).toHaveCount(0, { timeout: 30_000 }); + await page.goto(`${live.webUrl}${path}`, { waitUntil: "domcontentloaded" }); +} + +/** + * Pair the page with `codev-agent` through the form the phase built. + * + * Driven as a human drives it — typed into the real inputs and submitted — + * rather than by writing the credential into storage. A test that seeded storage + * would prove the storage format and say nothing about whether the form works, + * which is the entry point this phase's deliverable is about. + */ +async function pairWithAgent(page: Page): Promise { + const { agent: host } = ready(); + const token = host.pairings.issue(MACHINE_MINT).token; + await page.getByTestId("codev-gate-approval-pair").click(); + await expect(page.getByTestId("codev-pairing-panel")).toBeVisible(); + await expect(page.getByTestId("codev-pairing-target")).toHaveValue(TARGET_ID); + await page.getByTestId("codev-pairing-machine").fill(MACHINE_NAME); + await page.getByTestId("codev-pairing-workspace").fill(host.workspacePath); + await page.getByTestId("codev-pairing-token").fill(token); + await page.getByTestId("codev-pairing-submit").click(); + await expect(page.getByTestId("codev-pairing-panel")).toHaveCount(0, { timeout: 20_000 }); +} + +test("the pairing form pairs this browser, and the page never leaves its own origin", async ({ + page, +}) => { + const { stack: live, seeded: fixture } = ready(); + const requests = recordRequests(page); + + await openApp(page, "/"); + // Open the gated thread, where the gate panel and its Approve action live. + await openThread(page, fixture.builderTitle); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); + + await pairWithAgent(page); + + // The credential is live: the approve control appears, naming the machine and + // the configured target rather than an origin. + await expect(page.getByTestId("codev-gate-approval-machine")).toContainText(MACHINE_NAME); + await expect(page.getByTestId("codev-gate-approval-machine")).toContainText(TARGET_ID); + + /* + * THE SAME-ORIGIN ASSERTION, and it is about every request rather than about a + * header. `codev-agent` is on its own origin and the page has just talked to + * it; if any of that traffic went direct, it is in this list. + */ + const origin = new URL(live.webUrl).origin; + const foreign = crossOrigin(requests, origin); + expect(foreign, `the page issued cross-origin requests: ${foreign.join(", ")}`).toEqual([]); + // And it did reach codev-agent — over the proxy path, on this origin. Without + // this the assertion above passes on a page that made no agent request at all. + expect(requests.some((url) => url.startsWith(`${origin}/api/codev/agent/${TARGET_ID}/`))).toBe( + true, + ); +}); + +test("a pane shows the porch phase and the last messages codev-agent publishes", async ({ + page, +}) => { + const { stack: live } = ready(); + await openApp(page, "/"); + await openThread(page, ready().seeded.builderTitle); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); + await pairWithAgent(page); + + await page.goto(`${live.webUrl}/codev-builders`, { waitUntil: "domcontentloaded" }); + const panes = page.getByTestId("codev-builder-pane"); + await expect(panes.first()).toBeVisible({ timeout: 30_000 }); + + const managed = panes.filter({ hasText: ready().seeded.builderTitle }); + // The porch phase, from codev-agent. This is the line that read "Phase not read + // here yet" for three phases. + await expect(managed.getByTestId("codev-pane-phase")).toHaveAttribute( + "data-codev-pane-content", + "known", + { timeout: 30_000 }, + ); + // BOTH, not one: the gate says who is waiting, the phase says where they got + // to, and a pane that showed only the gate would drop the second. + await expect(managed.getByTestId("codev-pane-phase")).toContainText("implement"); + await expect(managed.getByTestId("codev-pane-phase")).toContainText(ready().seeded.gate.name); + // Three messages, newest first, and the fourth is not there. + await expect(managed.getByTestId("codev-pane-message")).toHaveCount(3); + await expect(managed.getByTestId("codev-pane-message").first()).toContainText( + "configured, never browser-selected", + ); + + /* + * AND THE PANE THAT CANNOT RESOLVE SAYS SO IN WORDS. + * + * codev-agent answered and does not publish this thread. A blank line here + * would be a claim about the builder rather than about what reached the + * browser, and it is the branch a screenshot of a fully-resolving grid hides. + */ + const unmanaged = panes.filter({ hasText: ready().seeded.unmanagedTitle }); + await expect(unmanaged.getByTestId("codev-pane-phase")).toHaveAttribute( + "data-codev-pane-content", + "absent", + ); + await expect(unmanaged.getByTestId("codev-pane-messages-note")).toContainText( + "does not publish this thread", + ); +}); + +test("approving from t3code writes the approval porch recorded", async ({ page }) => { + const { stack: live, seeded: fixture, agent: host } = ready(); + const requests = recordRequests(page); + + await openApp(page, "/"); + await openThread(page, fixture.builderTitle); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); + await pairWithAgent(page); + + // A session costs its own single-use token, distinct from the machine + // credential. The form asks for one, so this is the ceremony and not a bypass. + const sessionToken = host.pairings.issue(SESSION_MINT).token; + await page.getByTestId("codev-gate-session-token").fill(sessionToken); + await page.getByTestId("codev-gate-approve").click(); + + await expect(page.getByTestId("codev-gate-approval-approved")).toBeVisible({ timeout: 60_000 }); + const approved = page.getByTestId("codev-gate-approval-approved"); + await expect(approved).toContainText(AGENT_MACHINE); + + /* + * CRITERION 4. The record is porch's, in a real `status.yaml`, and the three + * fields the criterion names are all there. The page is reporting what the + * server said rather than what it did. + */ + const state = yaml.load(readFileSync(host.statusPathFor(BUILDER_ID), "utf8")) as any; + expect(state.gates[fixture.gate.name].status).toBe("approved"); + expect(state.gates[fixture.gate.name].approval.machine).toBe(AGENT_MACHINE); + expect(typeof state.gates[fixture.gate.name].approval.session_id).toBe("string"); + expect(Number.isNaN(Date.parse(state.gates[fixture.gate.name].approved_at))).toBe(false); + await expect(approved).toContainText(state.gates[fixture.gate.name].approval.session_id); + + // Still same-origin, through the whole approval. + const origin = new URL(live.webUrl).origin; + const foreign = crossOrigin(requests, origin); + expect(foreign, `the page issued cross-origin requests: ${foreign.join(", ")}`).toEqual([]); +}); + +/** + * The pictures the architect rules on. + * + * A green run is not the deliverable — nobody has approved these pane internals, + * and phase 9 deliberately left that open until real content filled them. + */ +for (const viewport of VIEWPORTS) { + test(`screenshots at ${viewport.name}`, async ({ page }) => { + const { stack: live, seeded: fixture } = ready(); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await openApp(page, "/"); + await openThread(page, fixture.builderTitle); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); + + // Unpaired first: this is what a human meets before they have a credential, + // and it is half of what the entry point is judged on. + await expect(page.getByTestId("codev-gate-approval-unpaired")).toBeVisible(); + await settleForScreenshot(page); + await page.screenshot({ + path: forkScreenshotPath("phase-10", `gate-unpaired-${viewport.name}`), + fullPage: false, + }); + + await page.getByTestId("codev-gate-approval-pair").click(); + await expect(page.getByTestId("codev-pairing-panel")).toBeVisible(); + await settleForScreenshot(page); + await page.screenshot({ + path: forkScreenshotPath("phase-10", `pairing-form-${viewport.name}`), + fullPage: false, + }); + + await page.getByTestId("codev-pairing-machine").fill(MACHINE_NAME); + await page.getByTestId("codev-pairing-workspace").fill(ready().agent.workspacePath); + await page.getByTestId("codev-pairing-token").fill(ready().agent.pairings.issue(MACHINE_MINT).token); + await page.getByTestId("codev-pairing-submit").click(); + await expect(page.getByTestId("codev-pairing-panel")).toHaveCount(0, { timeout: 20_000 }); + await settleForScreenshot(page); + await page.screenshot({ + path: forkScreenshotPath("phase-10", `gate-approve-${viewport.name}`), + fullPage: false, + }); + + await page.goto(`${live.webUrl}/codev-builders`, { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("codev-builder-pane").first()).toBeVisible({ timeout: 30_000 }); + await expect( + page + .getByTestId("codev-builder-pane") + .filter({ hasText: fixture.builderTitle }) + .getByTestId("codev-pane-phase"), + ).toHaveAttribute("data-codev-pane-content", "known", { timeout: 30_000 }); + await settleForScreenshot(page); + await page.screenshot({ + path: forkScreenshotPath("phase-10", `grid-with-content-${viewport.name}`), + fullPage: false, + }); + }); +} + + +/** + * Quiet the page down before a screenshot a human will judge. + * + * t3code's provider-update toast is real product chrome and nothing to do with + * this change, and it sits over the pane grid and the gate panel. Dismissed the + * way a user dismisses it rather than hidden with CSS, so what is captured is a + * state a user can actually be in. + */ +async function settleForScreenshot(page: Page): Promise { + const dismissals = page.getByRole("button", { name: /dismiss notification/i }); + for (let index = await dismissals.count(); index > 0; index -= 1) { + await dismissals.first().click(); + } + await page.waitForTimeout(700); +} + +/** + * Open one thread the way a user does. + * + * `page.goto` on the thread route proves a route renders and says nothing about + * whether anything links to it — the defect the phase 9 review found in this + * suite. The sidebar row is how a person gets here. + */ +async function openThread(page: Page, title: string): Promise { + const row = page.locator('[data-testid="sidebar-row-card"]').filter({ hasText: title }).first(); + try { + await row.waitFor({ state: "visible", timeout: 5_000 }); + } catch { + // Off-canvas at 390. The toggle is how a person reveals it there. + const toggle = page.getByRole("button", { name: /toggle (main )?sidebar/i }).first(); + if ((await toggle.count()) > 0) await toggle.click(); + await row.waitFor({ state: "visible", timeout: 30_000 }); + } + await row.click(); +} diff --git a/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts b/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts new file mode 100644 index 000000000..2c447457e --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts @@ -0,0 +1,811 @@ +/** + * Spec 250, phase 7 — bringing up the fork's own web app so a browser can be + * pointed at it. + * + * ## Why this is a fixture and not four lines in the spec + * + * The thing under test is t3code's OWN sidebar, rendered by t3code's OWN web + * app, against t3code's OWN server. Nothing about that stack is Codev's, and + * three separate pieces have to agree before a single assertion is meaningful: + * + * the fork SERVER `t3-server.mjs start-fork`, which runs `apps/server/src/bin.ts` + * from the fork checkout. The published `t3` CLI will not do: + * its contract has no `role` and no `parentThreadId`, so the + * decoder strips them and the sidebar sees a flat list. That + * run would pass and prove nothing. + * the fork WEB app Vite, from `apps/web`, proxying `/api` and `/ws` to the + * server. This fixture does NOT start it — see `probeWebApp`. + * a PAIRED browser the server issues one single-use bootstrap token per start, + * and it is consumed by the first exchange. So the fixture + * spends it once on an access token and mints the browser's + * pairing credential from that. + * + * ## The server is restarted, on purpose + * + * `start-fork` without `--keep-data` starts on an empty data directory. That is + * what makes the assertions about ORDER meaningful: a sidebar carrying threads + * from three previous runs can satisfy "the tree renders" while saying nothing + * about what is above what. It also re-mints the bootstrap token, which `ready` + * redacts from the log after handing it over exactly once. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** Scopes Codev asks for, plus the one that mints a browser's pairing credential. */ +const SEED_SCOPES = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + "access:write", +] as const; + +/** + * Where the fork's server writes the gate-writer credential at start. + * + * Phase 8's fixture needs to write a gate, and it cannot ask for the scope: a + * bootstrap exchange requesting `codev:gate-write` is refused with + * `invalid_scope`, which is the phase 4 design working. Gate writes are meant to + * come from ONE credential — `codev-agent`, scoped to `orchestration:read` and + * `codev:gate-write` and nothing else — provisioned by the server rather than + * derived from whatever token a client happens to hold. + * + * So the fixture reads that credential from where the server put it, which is + * also exactly what `thread-backend.ts` does in production. A fixture that + * obtained the ability some other way would be testing a path no writer uses. + */ +const GATE_WRITER_TOKEN_RELATIVE_PATH = "codev/gate-writer.token"; + +export interface ForkStackUnavailable { + readonly available: false; + /** + * Why, in a sentence a human can act on. + * + * Every caller turns this into a SKIP, never a pass. An unreachable dev server + * and a sidebar with no tree in it are different facts, and spelling them the + * same way is how a suite reports "I could not tell" as "no". + */ + readonly reason: string; +} + +export interface ForkStackReady { + readonly available: true; + readonly webUrl: string; + readonly serverBase: string; + readonly accessToken: string; + /** + * The server-provisioned gate-writer credential, or `null` when the server + * did not write one. + * + * `null` is not "no gates": it means this run cannot write one, and the caller + * reports that as a skip. An unwritable credential and a gate that failed to + * render are different facts. + */ + readonly gateWriterToken: string | null; +} + +export type ForkStack = ForkStackReady | ForkStackUnavailable; + +export interface SeededHierarchy { + readonly projectId: string; + readonly projectTitle: string; + /** + * Phase 8. Two gated builders, because the panel has THREE states and only + * one of them is "no gate": a builder carrying #128's structured request, and + * one carrying a gate with nothing attached — which is what + * `porch gate ` without `--request-file` produces, and the state most + * likely to be rendered as "no gate" by mistake. + */ + readonly gatedBuilderId: string; + readonly unstructuredGateBuilderId: string; + /** + * An ARCHITECT at a gate, which is the case a human most needs to find. + * + * It is also the one place two markers compete for the same row: the role + * caption and the gate marker. Seeded so the answer is measured rather than + * assumed. + */ + readonly gatedArchitectId: string; + readonly gate: { + readonly name: string; + readonly unstructuredName: string; + readonly architectName: string; + readonly question: string; + readonly recommendedLabel: string; + readonly recommendedConsequence: string; + readonly otherLabel: string; + readonly otherConsequence: string; + }; + readonly architectAlpha: string; + readonly architectBeta: string; + /** Archived after its builder was created, which is what orphans the builder. */ + readonly architectGhost: string; + readonly titles: { + readonly architectAlpha: string; + readonly buildersAlpha: readonly string[]; + readonly architectBeta: string; + readonly builderBeta: string; + readonly plain: string; + readonly orphan: string; + }; +} + +const repoRoot = resolve(import.meta.dirname, "../../../../.."); +const harness = resolve(repoRoot, "tools/t3-server/t3-server.mjs"); + +function harnessEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + T3CODE_ROOT: process.env.T3CODE_ROOT ?? "/Users/chris/dev/t3code", + T3CODE_FORK_ROOT: process.env.T3CODE_FORK_ROOT ?? "/Users/chris/dev/t3code-codev", + T3_HARNESS_PORT: process.env.T3_HARNESS_PORT ?? "3811", + }; +} + +function runHarness(...args: readonly string[]): string { + return execFileSync("node", [harness, ...args], { encoding: "utf8", env: harnessEnv() }); +} + +export function webAppUrl(): string { + return process.env.T3_WEB_URL ?? "http://localhost:5733"; +} + +/** + * Is the fork's web app answering? + * + * This fixture will start the SERVER but never the web app: Vite dev is a + * long-lived foreground process a test has no business owning, and a test that + * silently started one would leave it running after a failure. So an absent web + * app is a skip with instructions, not an attempt to fix it. + */ +async function probeWebApp(): Promise { + const url = webAppUrl(); + try { + const response = await fetch(url, { signal: AbortSignal.timeout(4_000) }); + if (!response.ok) return `${url} answered ${response.status}`; + return null; + } catch (error) { + return `${url} is not answering (${error instanceof Error ? error.message : String(error)})`; + } +} + +/** + * Start the fork SERVER on empty data, without requiring the web app. + * + * Split out of `startForkStack` in phase 10. Phases 7-9 were about what a browser + * renders, so a missing Vite dev server made a run meaningless. Phase 10's proxy + * is an HTTP surface on the fork's own server, and a test that drives it over + * `fetch` needs no page at all — requiring one would skip a run that could have + * answered. + * + * The server reads its codev-agent allowlist from `T3CODE_CODEV_AGENT_ORIGINS` in + * its OWN environment, and the harness spawns it with `process.env`. So a caller + * configures the proxy by setting that variable before calling — the same act an + * operator performs, rather than a fixture-only back door. + * + * Every failure returns a REASON rather than throwing: each one means "this run + * cannot tell you anything", not "the thing under test is wrong". + */ +export async function startForkServer(): Promise< + ForkStackUnavailable | Omit +> { + const forkRoot = harnessEnv().T3CODE_FORK_ROOT; + if (forkRoot === undefined || !existsSync(forkRoot)) { + return { + available: false, + reason: + `T3CODE_FORK_ROOT is ${forkRoot ?? "unset"}, which does not exist. This spec is ABOUT ` + + `the fork; there is nothing to fall back to.`, + }; + } + if (process.env.T3_NODE === undefined) { + return { + available: false, + reason: + "T3_NODE is unset. The fork server runs under its own interpreter and does not inherit " + + "one from PATH.", + }; + } + + try { + runHarness("stop"); + } catch { + // Nothing was running. `stop` on an idle port is not a failure. + } + let bootstrapToken: string; + try { + runHarness("start-fork"); + const readyOutput = runHarness("ready"); + const parsed: unknown = JSON.parse(readyOutput.slice(readyOutput.indexOf("{"))); + const token = + typeof parsed === "object" && parsed !== null && "token" in parsed + ? (parsed as { token: unknown }).token + : undefined; + if (typeof token !== "string") { + return { available: false, reason: "the fork server started but printed no pairing token" }; + } + bootstrapToken = token; + } catch (error) { + return { + available: false, + reason: `the fork server would not start: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, + }; + } + + const serverBase = `http://127.0.0.1:${harnessEnv().T3_HARNESS_PORT}`; + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: bootstrapToken, + subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + scope: SEED_SCOPES.join(" "), + client_label: "codev-spec-250-e2e", + client_device_type: "bot", + }); + const tokenResponse = await fetch(`${serverBase}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + if (!tokenResponse.ok) { + return { + available: false, + reason: `the bootstrap exchange failed with ${tokenResponse.status}`, + }; + } + const access = (await tokenResponse.json()) as { readonly access_token: string }; + return { + available: true, + serverBase, + accessToken: access.access_token, + gateWriterToken: readGateWriterToken(), + }; +} + +/** + * Start the fork server AND require its web app, for the specs that render pages. + * + * The web app is probed FIRST, before anything is started: an absent Vite is a + * skip with instructions, and starting a server for a run that cannot proceed + * leaves a process behind for nothing. + */ +export async function startForkStack(): Promise { + const webProblem = await probeWebApp(); + if (webProblem !== null) { + return { + available: false, + reason: + `${webProblem}. Start it with: T3CODE_SINGLE_ORIGIN_DEV=1 T3CODE_PORT=3811 PORT=5733 ` + + `npx vp dev, from the fork's apps/web.`, + }; + } + const server = await startForkServer(); + if (!server.available) return server; + return { ...server, webUrl: webAppUrl() }; +} + +/** + * Where a viewport's screenshot goes. + * + * The committed copies live in the FORK, under `docs/codev/spec-250//` — + * they are pictures of the fork's UI, and `docs/codev/` is Codev's own rather + * than mixed into upstream's `architecture` / `internals` / `operations`, which + * belong to pingdotgg. + * + * **Nothing writes into the fork directly, and that is not tidiness.** + * `t3-server.mjs start-fork` refuses a dirty fork checkout, so a suite whose + * screenshots landed in the fork would poison itself: the first spec file writes + * new PNG bytes, and every spec file after it SKIPS because the tree it needs is + * now dirty. It passes, it skips the rest, and the skip is correct behaviour — + * which is exactly what makes it easy to miss. + * + * So a run always writes somewhere outside the fork. Refreshing the committed + * pictures is a copy afterwards: + * + * SPEC_250_SCREENSHOT_DIR=/tmp/spec-250-shots \ + * npx playwright test --config playwright.spec250.config.ts + * cp -R /tmp/spec-250-shots/. "$T3CODE_FORK_ROOT/docs/codev/spec-250/" + * + * then commit them in the fork. + */ +export function forkScreenshotPath(phase: string, name: string): string { + const root = + process.env.SPEC_250_SCREENSHOT_DIR ?? + resolve(import.meta.dirname, "../../../test-results/spec-250-screenshots"); + return resolve(root, phase, `${name}.png`); +} + +/** + * Read the gate-writer credential the fork's server wrote at start. + * + * Returns `null` rather than throwing: a server without gate support writes no + * such file, and that is a reason to skip, not a crash. + */ +function readGateWriterToken(): string | null { + const runtimeDataDir = + process.env.T3_HARNESS_DIR !== undefined + ? resolve(process.env.T3_HARNESS_DIR, "data") + : resolve(repoRoot, "tools/t3-server/.runtime/data"); + const tokenPath = resolve(runtimeDataDir, GATE_WRITER_TOKEN_RELATIVE_PATH); + try { + const token = readFileSync(tokenPath, "utf8").trim(); + // An empty file is a half-written credential, not an empty one. The server + // writes `.partial` and renames precisely so a reader never sees that, but a + // truncated token authenticates like a revoked one. + return token === "" ? null : token; + } catch { + return null; + } +} + +export function stopForkStack(): void { + try { + runHarness("stop"); + } catch { + // Already gone. + } +} + +/** + * A fresh single-use pairing credential for one browser context. + * + * One per context, always. The server consumes them on use, so a second page + * opened with the same credential lands on the pairing form — which looks + * exactly like a broken sidebar and is not one. + */ +export async function mintPairingCredential(stack: ForkStackReady): Promise { + const response = await fetch(`${stack.serverBase}/api/auth/pairing-token`, { + method: "POST", + headers: { + authorization: `Bearer ${stack.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ label: "spec-250-e2e" }), + }); + if (!response.ok) { + throw new Error(`could not mint a pairing credential: ${response.status}`); + } + const { credential } = (await response.json()) as { readonly credential: string }; + return credential; +} + +interface RpcClient { + call(method: string, payload: unknown): Promise; +} + +const uniqueId = (): string => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + +/** + * Seed one project holding every shape the sidebar has to tell apart. + * + * The orphan is made the way a real one is made — an architect is archived after + * its builder exists — and not by writing an illegal edge. Phase 3 refuses those + * at write time, so a fixture that produced one would be testing a state this + * server cannot reach. + */ +async function connect( + stack: ForkStackReady, + accessToken: string, +): Promise<{ client: RpcClient; close: () => void }> { + const clientModule = await import("@cluesmith/t3-client/client"); + const authModule = await import("@cluesmith/t3-client/auth"); + const ticket = await authModule.issueWebSocketTicket(stack.serverBase, accessToken); + /* + * `ws` when the runtime has no global WebSocket, which Node 20 does not. + * + * The fork's own tooling wants Node 22, and `better-sqlite3` in this repository + * is built for Node 20 — so a phase-10 run that needs both a codev-agent and + * this socket cannot simply pick one interpreter. The polyfill is narrower than + * the alternative (a child process per agent host) and it is the same protocol + * either way; `globalThis.WebSocket` is preferred whenever it exists, so a Node + * 22 run is byte-for-byte what it always was. + */ + const WebSocketImpl: typeof WebSocket = + typeof globalThis.WebSocket === "function" + ? globalThis.WebSocket + : ((await import("ws")).default as unknown as typeof WebSocket); + const socket = new WebSocketImpl(authModule.webSocketUrl(stack.serverBase, ticket.ticket)); + await new Promise((resolveOpen, rejectOpen) => { + socket.addEventListener("open", () => resolveOpen(), { once: true }); + socket.addEventListener("error", () => rejectOpen(new Error("socket error")), { once: true }); + }); + const client = new clientModule.T3Client( + { + send: (data: string) => socket.send(data), + close: () => socket.close(), + addEventListener: (type: string, listener: (event: unknown) => void) => + socket.addEventListener(type as "message", listener as EventListener), + get readyState() { + return socket.readyState; + }, + }, + { requestTimeoutMs: 45_000 }, + ) as unknown as RpcClient; + return { client, close: () => socket.close() }; +} + +export async function seedHierarchy(stack: ForkStackReady): Promise { + const { client, close } = await connect(stack, stack.accessToken); + + const projectId = uniqueId(); + const projectTitle = "spec 250 sidebar"; + const forkRoot = harnessEnv().T3CODE_FORK_ROOT ?? ""; + await client.call("orchestration.dispatchCommand", { + type: "project.create", + commandId: uniqueId(), + projectId, + title: projectTitle, + workspaceRoot: forkRoot, + defaultModelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + createdAt: new Date().toISOString(), + }); + + const createThread = async (fields: Record): Promise => { + await client.call("orchestration.dispatchCommand", { + type: "thread.create", + commandId: uniqueId(), + modelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: forkRoot, + createdAt: new Date().toISOString(), + projectId, + ...fields, + }); + }; + + const titles = { + architectAlpha: "Architect alpha", + // None of these names the gate. Spec 146 wrote the gate into the TITLE + // because there was nowhere else; the assertion that it no longer does + // needs titles that would make a leak visible. + buildersAlpha: ["Builder alpha one", "Builder alpha two", "Builder alpha three"], + architectBeta: "Architect beta", + builderBeta: "Builder beta one", + plain: "Plain upstream thread", + orphan: "Orphaned builder", + } as const; + + const architectAlpha = uniqueId(); + const architectBeta = uniqueId(); + const architectGhost = uniqueId(); + + await createThread({ threadId: architectAlpha, title: titles.architectAlpha, role: "architect" }); + const alphaBuilderIds: string[] = []; + for (const title of titles.buildersAlpha) { + const threadId = uniqueId(); + alphaBuilderIds.push(threadId); + await createThread({ + threadId, + title, + role: "builder", + parentThreadId: architectAlpha, + }); + } + await createThread({ threadId: architectBeta, title: titles.architectBeta, role: "architect" }); + await createThread({ + threadId: uniqueId(), + title: titles.builderBeta, + role: "builder", + parentThreadId: architectBeta, + }); + await createThread({ threadId: uniqueId(), title: titles.plain }); + await createThread({ threadId: architectGhost, title: "Architect ghost", role: "architect" }); + await createThread({ + threadId: uniqueId(), + title: titles.orphan, + role: "builder", + parentThreadId: architectGhost, + }); + await client.call("orchestration.dispatchCommand", { + type: "thread.archive", + commandId: uniqueId(), + threadId: architectGhost, + }); + + // ------------------------------------------------------------- the gates + // + // Written through `codev.gateWrite`, the same RPC and the same scope + // `codev-agent` uses. Not by writing the column, and not by dispatching a + // thread command: the revision is server-allocated, and a fixture that + // invented one would be seeding a state the real writer cannot produce. + const gatedBuilderId = alphaBuilderIds[0]; + const unstructuredGateBuilderId = alphaBuilderIds[1]; + if (gatedBuilderId === undefined || unstructuredGateBuilderId === undefined) { + throw new Error("unreachable: three alpha builders are created above"); + } + const gate = { + name: "plan-approval", + unstructuredName: "spec-approval", + architectName: "verify-approval", + question: "Delete the legacy table, or keep it for audit purposes?", + recommendedLabel: "Delete it", + recommendedConsequence: "Migrate references, drop the table, and open the PR.", + otherLabel: "Keep it", + otherConsequence: "Retain the table and document the audit dependency.", + } as const; + + // + // On its OWN connection, with the server's own credential. Not on the socket + // above: that one carries `orchestration:operate`, and putting gate writes on + // it is precisely what phase 4 gave the method a separate scope to prevent. + if (stack.gateWriterToken === null) { + throw new Error( + "the fork server wrote no gate-writer credential, so this fixture cannot write a gate", + ); + } + const gateWriter = await connect(stack, stack.gateWriterToken); + await gateWriter.client.call("codev.gateWrite", { + type: "codev.gate.set", + commandId: uniqueId(), + threadId: gatedBuilderId, + createdAt: new Date().toISOString(), + gate: { + gateName: gate.name, + requestedAt: new Date().toISOString(), + question: gate.question, + choices: [ + { + label: gate.recommendedLabel, + consequence: gate.recommendedConsequence, + recommended: true, + }, + { label: gate.otherLabel, consequence: gate.otherConsequence }, + ], + terminalExcerpt: "warning: legacy references remain\ncheckout tests failed", + }, + }); + await gateWriter.client.call("codev.gateWrite", { + type: "codev.gate.set", + commandId: uniqueId(), + threadId: unstructuredGateBuilderId, + createdAt: new Date().toISOString(), + gate: { gateName: gate.unstructuredName, requestedAt: new Date().toISOString() }, + }); + await gateWriter.client.call("codev.gateWrite", { + type: "codev.gate.set", + commandId: uniqueId(), + threadId: architectBeta, + createdAt: new Date().toISOString(), + gate: { + gateName: gate.architectName, + requestedAt: new Date().toISOString(), + question: "Merge the branch, or hold for the second review?", + choices: [{ label: "Merge", consequence: "Merge and close the issue." }], + }, + }); + + gateWriter.close(); + close(); + return { + projectId, + projectTitle, + architectAlpha, + architectBeta, + architectGhost, + titles, + gatedBuilderId, + unstructuredGateBuilderId, + gatedArchitectId: architectBeta, + gate, + }; +} + + +export interface SeededTiling { + readonly projectId: string; + readonly projectTitle: string; + readonly architectTitle: string; + readonly builderTitles: readonly string[]; +} + +/** + * One architect and six builders, and nothing else, for the tiling measurement. + * + * A separate seeding from `seedHierarchy` on purpose. The grid is not scoped to + * a project — it shows the agents Codev is running in this workspace — so a run + * that also carried the hierarchy fixture's threads would be measuring a grid + * with a pane count nobody chose. The fixture restarts the server on empty data, + * which is what makes "exactly seven panes" a fact rather than a hope. + * + * SEVEN panes is the point. Criterion 5 wants six builders watchable at + * 1440x900; criterion 5b wants seven panes at 1920 tiling 4x2 rather than 3x3, + * and seven is the count that tells the fewest-rows rule apart from a + * near-square one — both give three columns at 1440. + */ +export async function seedTiling(stack: ForkStackReady): Promise { + const { client, close } = await connect(stack, stack.accessToken); + const projectId = uniqueId(); + const projectTitle = "spec 250 tiling"; + const forkRoot = harnessEnv().T3CODE_FORK_ROOT ?? ""; + await client.call("orchestration.dispatchCommand", { + type: "project.create", + commandId: uniqueId(), + projectId, + title: projectTitle, + workspaceRoot: forkRoot, + defaultModelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + createdAt: new Date().toISOString(), + }); + + const createThread = async (fields: Record): Promise => { + await client.call("orchestration.dispatchCommand", { + type: "thread.create", + commandId: uniqueId(), + modelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: forkRoot, + createdAt: new Date().toISOString(), + projectId, + ...fields, + }); + }; + + const architectTitle = "Architect main"; + const architectId = uniqueId(); + await createThread({ threadId: architectId, title: architectTitle, role: "architect" }); + const builderTitles = [ + "Builder one", + "Builder two", + // Deliberately long, and it is the only reason the role-prefix test can + // fail. With six short titles a pane never runs out of room, so a prefix + // that COULD be clipped never is — and a test that cannot fail is not a + // test. Real builder threads are named `builder/spir-250 gate rendering in + // t3code` and worse. + "Builder three with a deliberately very long thread title that will not fit a pane", + "Builder four", + "Builder five", + "Builder six", + ]; + for (const title of builderTitles) { + await createThread({ + threadId: uniqueId(), + title, + role: "builder", + parentThreadId: architectId, + }); + } + + close(); + return { projectId, projectTitle, architectTitle, builderTitles }; +} + +/** + * Spec 250, phase 10 — threads whose IDS are returned, so a `codev-agent` can be + * seeded to publish about them. + * + * `seedHierarchy` and `seedTiling` return titles, because those specs read the + * sidebar and the grid by what a human sees. This one has a second consumer: the + * agent host, whose identities carry `thread_id` and must line up with the + * threads t3code is showing, or every pane truthfully reports "codev-agent does + * not publish this thread" and the phase's content is never rendered. + */ +export interface SeededApproval { + readonly projectId: string; + readonly architectThreadId: string; + readonly architectTitle: string; + readonly builderThreadId: string; + readonly builderTitle: string; + /** A second builder, with no porch project, so "absent" is rendered too. */ + readonly unmanagedThreadId: string; + readonly unmanagedTitle: string; + readonly gate: { + readonly name: string; + readonly question: string; + readonly recommendedLabel: string; + }; +} + +export async function seedApproval(stack: ForkStackReady): Promise { + const { client, close } = await connect(stack, stack.accessToken); + const projectId = uniqueId(); + const forkRoot = harnessEnv().T3CODE_FORK_ROOT ?? ""; + await client.call("orchestration.dispatchCommand", { + type: "project.create", + commandId: uniqueId(), + projectId, + title: "spec 250 approval", + workspaceRoot: forkRoot, + defaultModelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + createdAt: new Date().toISOString(), + }); + + const createThread = async (fields: Record): Promise => { + await client.call("orchestration.dispatchCommand", { + type: "thread.create", + commandId: uniqueId(), + modelSelection: { instanceId: "codex", model: "gpt-5.6-luna" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: forkRoot, + createdAt: new Date().toISOString(), + projectId, + ...fields, + }); + }; + + const architectThreadId = uniqueId(); + const architectTitle = "Architect main"; + await createThread({ threadId: architectThreadId, title: architectTitle, role: "architect" }); + + const builderThreadId = uniqueId(); + const builderTitle = "Builder at a gate"; + await createThread({ + threadId: builderThreadId, + title: builderTitle, + role: "builder", + parentThreadId: architectThreadId, + }); + + /* + * A builder codev-agent knows nothing about. + * + * "The agent answered and does not publish this thread" is a real, ordinary + * state and its own branch in the pane. Without one seeded, that branch is + * never rendered and the screenshots would show a grid where every pane + * happens to resolve. + */ + const unmanagedThreadId = uniqueId(); + const unmanagedTitle = "Builder codev-agent does not know"; + await createThread({ + threadId: unmanagedThreadId, + title: unmanagedTitle, + role: "builder", + parentThreadId: architectThreadId, + }); + + const gate = { + name: "pr", + question: "Approve the plan, or send it back for another round?", + recommendedLabel: "Approve", + } as const; + + // Through `codev.gateWrite`, with the server's own credential, on its own + // connection — the same RPC and scope `codev-agent` uses. See `seedHierarchy`. + if (stack.gateWriterToken === null) { + throw new Error( + "the fork server wrote no gate-writer credential, so this fixture cannot write a gate", + ); + } + const gateWriter = await connect(stack, stack.gateWriterToken); + await gateWriter.client.call("codev.gateWrite", { + type: "codev.gate.set", + commandId: uniqueId(), + threadId: builderThreadId, + createdAt: new Date().toISOString(), + gate: { + gateName: gate.name, + requestedAt: new Date().toISOString(), + question: gate.question, + choices: [ + { + label: gate.recommendedLabel, + consequence: "Implementation starts on the plan as written.", + recommended: true, + }, + { label: "Send it back", consequence: "The plan is revised first." }, + ], + }, + }); + gateWriter.close(); + close(); + + return { + projectId, + architectThreadId, + architectTitle, + builderThreadId, + builderTitle, + unmanagedThreadId, + unmanagedTitle, + gate, + }; +} diff --git a/packages/codev/src/__tests__/e2e/spec-250-gate.spec.ts b/packages/codev/src/__tests__/e2e/spec-250-gate.spec.ts new file mode 100644 index 000000000..8ba7a2a0b --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-gate.spec.ts @@ -0,0 +1,332 @@ +/** + * Spec 250, phase 8 — a porch gate, in t3code's own web app. + * + * ## What this has to prove that a unit test cannot + * + * `gateState.test.ts` settles the three states and `GatePanel.test.tsx` settles + * what each one renders. Neither can say the gate SURVIVES THE TRIP: the block + * is written through `codev.gateWrite`, allocated a revision by the server, + * projected onto the thread, and delivered to the browser by subscription. Every + * defect this project has found so far lived in exactly that kind of hop — phase + * 3's engine rewriting refusals, phase 4's doing it again, phase 6's ws layer + * flattening a discriminant — and all of them were green underneath. + * + * So the gate here is written by the same RPC and the same scope `codev-agent` + * uses, against a server built from the fork's source, and read out of a real + * browser. + * + * ## The criterion that is about ABSENCE + * + * Spec 146 wrote the gate name into the thread TITLE, because t3code had nowhere + * else to put it. The plan asks that the title carry no gate name anywhere in the + * flow, and an absence is the easiest thing to assert wrongly: a test that only + * checked "the panel shows plan-approval" would pass on a build that ALSO put it + * back in the title. So the fixture's titles never contain a gate name, and the + * assertions check the title elements themselves. + * + * Running it: see `spec-250-hierarchy.spec.ts` — same stack, same commands, and + * `pnpm test:e2e:spec250` runs both. + */ + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { + forkScreenshotPath, + mintPairingCredential, + seedHierarchy, + startForkStack, + stopForkStack, + type ForkStackReady, + type SeededHierarchy, +} from "./spec-250-fork-stack"; + +let stack: ForkStackReady | null = null; +let seeded: SeededHierarchy | null = null; +let unavailable: string | null = null; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + const started = await startForkStack(); + if (!started.available) { + unavailable = started.reason; + return; + } + stack = started; + seeded = await seedHierarchy(started); +}); + +test.afterAll(() => { + if (stack !== null) stopForkStack(); +}); + +test.beforeEach(() => { + test.skip(unavailable !== null, unavailable ?? ""); +}); + +const VIEWPORTS = [ + { name: "390", width: 390, height: 844 }, + { name: "1440x900", width: 1440, height: 900 }, + { name: "1920", width: 1920, height: 1080 }, +] as const; + +const MINIMUM_BODY_TEXT_PX = 13; + +function ready(): { stack: ForkStackReady; seeded: SeededHierarchy } { + if (stack === null || seeded === null) { + throw new Error("unreachable: the fork stack is checked before this runs"); + } + return { stack, seeded }; +} + +/** Pair a fresh browser context and land on the sidebar. See the phase 7 spec. */ +async function openSidebar(page: Page): Promise { + const { stack: live } = ready(); + const credential = await mintPairingCredential(live); + await page.goto(`${live.webUrl}/pair#token=${credential}`, { waitUntil: "domcontentloaded" }); + await expect( + page.getByText("Enter a pairing token to start a session"), + "the browser did not pair; every assertion below would be about the pairing form", + ).toHaveCount(0, { timeout: 30_000 }); + await revealSidebar(page); +} + +async function revealSidebar(page: Page): Promise { + const tree = page.getByTestId("sidebar-codev-architect").first(); + try { + await tree.waitFor({ state: "visible", timeout: 5_000 }); + return; + } catch { + // Off-canvas at 390, or not rendered at all. The toggle distinguishes them. + } + const toggle = page.getByRole("button", { name: /toggle (main )?sidebar/i }).first(); + if ((await toggle.count()) > 0) await toggle.click(); + await tree.waitFor({ state: "visible", timeout: 20_000 }); +} + +/** The sidebar row for one thread, found by the title the fixture gave it. */ +function rowFor(page: Page, title: string): Locator { + return page.locator('[data-testid="sidebar-row-card"]').filter({ hasText: title }); +} + +/** Open a thread and wait for its gate panel. */ +async function openGatedThread(page: Page, title: string): Promise { + await rowFor(page, title).first().click(); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); +} + +async function settleForScreenshot(page: Page): Promise { + const dismissals = page.getByRole("button", { name: /dismiss notification/i }); + for (let index = await dismissals.count(); index > 0; index -= 1) { + await dismissals.first().click(); + } + await page.waitForTimeout(700); +} + +/** Criterion 3. */ +test("a builder stopped at a gate shows the gate name, question and choices", async ({ page }) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + await openGatedThread(page, fixture.titles.buildersAlpha[0] ?? ""); + + const panel = page.getByTestId("codev-gate-panel"); + await expect(panel).toHaveAttribute("data-codev-gate-name", fixture.gate.name); + await expect(panel).toHaveAttribute("data-codev-gate-kind", "pending"); + await expect(panel).toContainText(fixture.gate.name); + await expect(page.getByTestId("codev-gate-question")).toContainText(fixture.gate.question); + + const choices = page.getByTestId("codev-gate-choice"); + await expect(choices).toHaveCount(2); + // Label AND consequence for each: a panel that showed the labels alone would + // be asking a human to choose without telling them what either choice does. + await expect(choices.nth(0)).toContainText(fixture.gate.recommendedLabel); + await expect(choices.nth(0)).toContainText(fixture.gate.recommendedConsequence); + await expect(choices.nth(1)).toContainText(fixture.gate.otherLabel); + await expect(choices.nth(1)).toContainText(fixture.gate.otherConsequence); + + // Exactly one recommendation, on the choice that carried it, in place. + await expect(page.locator('[data-codev-gate-recommended="true"]')).toHaveCount(1); + await expect(choices.nth(0)).toHaveAttribute("data-codev-gate-recommended", "true"); + await expect(choices.nth(1)).toHaveAttribute("data-codev-gate-recommended", "false"); +}); + +/** + * The third state, in the browser. + * + * `porch gate ` without `--request-file` is legitimate and common. Rendering + * it as "no gate" hides a human who is waiting. + */ +test("a gate with no structured request says so, and is not mistaken for no gate", async ({ + page, +}) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + await openGatedThread(page, fixture.titles.buildersAlpha[1] ?? ""); + + const panel = page.getByTestId("codev-gate-panel"); + await expect(panel).toHaveAttribute("data-codev-gate-kind", "pending-unstructured"); + await expect(panel).toContainText(fixture.gate.unstructuredName); + await expect(panel).toContainText("Gate pending, no structured request"); + // Not an empty question and not an empty choice list — a heading with nothing + // under it reads as a broken gate rather than an absent request. + await expect(page.getByTestId("codev-gate-question")).toHaveCount(0); + await expect(page.getByTestId("codev-gate-choices")).toHaveCount(0); +}); + +test("a thread with no gate shows no panel at all", async ({ page }) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + // The third alpha builder was never gated. Every ungated thread has to look + // exactly as it did before spec 250. + await rowFor(page, fixture.titles.buildersAlpha[2] ?? "").first().click(); + await expect(page.getByTestId("codev-gate-panel")).toHaveCount(0); + await expect(page.getByTestId("sidebar-codev-gate-pill")).toHaveCount(3); +}); + +/** + * The gated row is distinguishable from every other row, and from a settled one. + * + * `starting` / `running` / `ready` / `settled` cannot express "blocked on a + * human", so this is its own marker rather than a session status. + */ +test("the sidebar marks the gated builders and nothing else", async ({ page }) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + + const pills = page.getByTestId("sidebar-codev-gate-pill"); + await expect(pills).toHaveCount(3); + await expect(pills.filter({ hasText: fixture.gate.name })).toHaveCount(1); + await expect(pills.filter({ hasText: fixture.gate.unstructuredName })).toHaveCount(1); + await expect(pills.filter({ hasText: fixture.gate.architectName })).toHaveCount(1); + + // On the right rows, and on no others. + await expect(rowFor(page, fixture.titles.buildersAlpha[0] ?? "")).toContainText( + fixture.gate.name, + ); + await expect(rowFor(page, fixture.titles.buildersAlpha[2] ?? "")).not.toContainText( + fixture.gate.name, + ); + await expect(rowFor(page, fixture.titles.architectAlpha)).not.toContainText(fixture.gate.name); + + // The marker outlives a hover. The status slot beside it fades to make room + // for the row actions, and a gate that vanished when someone reached for the + // row would be missing exactly when it was being acted on. + await rowFor(page, fixture.titles.buildersAlpha[0] ?? "").first().hover(); + await expect(pills.filter({ hasText: fixture.gate.name })).toBeVisible(); +}); + +/** + * An ARCHITECT at a gate keeps BOTH markers. + * + * It is the case a human most needs to find, and the one row where two markers + * compete: the role caption and the gate marker sit on the same line. Neither + * wins — "which agent is this" and "is it blocking on me" are different + * questions, and a row that dropped either would answer only one of them. Held + * up here rather than assumed from reading the JSX. + */ +test("a gated architect shows the role AND the gate, not one or the other", async ({ page }) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + + const row = rowFor(page, fixture.titles.architectBeta); + await expect(row).toHaveCount(1); + // BOTH, and both legible: the role caption is not truncated away to make room + // for the gate, and the gate is not truncated away to keep the caption. + await expect(row).toContainText("Architect"); + await expect(row).toContainText(fixture.gate.architectName); + await expect(row).toContainText(fixture.titles.architectBeta); + + // The subtree still reads as a subtree: its builder is still nested under it. + const subtree = page.locator( + `[data-testid="sidebar-codev-architect"][data-codev-architect-thread-id="${fixture.gatedArchitectId}"]`, + ); + await expect(subtree).toHaveCount(1); + await expect(subtree).toContainText(fixture.titles.builderBeta); +}); + +/** + * THE ABSENCE. Spec 146 put the gate name in the thread title; this asserts it + * is nowhere near one. + */ +test("no thread title anywhere in the flow contains a gate name", async ({ page }) => { + const { seeded: fixture } = ready(); + await openSidebar(page); + + // Sidebar titles. `span.text-sm` is the thread title; the project label and + // the status beside it are `text-xs`. + const titles = await page.locator('[data-testid="sidebar-row-card"] span.text-sm').allInnerTexts(); + expect(titles.length).toBeGreaterThan(0); + for (const title of titles) { + expect(title, "a sidebar thread title carried the gate name").not.toContain(fixture.gate.name); + expect(title).not.toContain(fixture.gate.unstructuredName); + } + + // And the open thread's own header, which is the other place a title renders. + await openGatedThread(page, fixture.titles.buildersAlpha[0] ?? ""); + const header = page.locator("header").first(); + if ((await header.count()) > 0) { + await expect(header).not.toContainText(fixture.gate.name); + } + const openTitles = await page + .locator('[data-testid="sidebar-row-card"] span.text-sm') + .allInnerTexts(); + for (const title of openTitles) { + expect(title).not.toContain(fixture.gate.name); + } +}); + +for (const viewport of VIEWPORTS) { + test(`the gate panel at ${viewport.name}: measured, screenshotted, and free of console errors`, async ({ + page, + }) => { + const { seeded: fixture } = ready(); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await openSidebar(page); + await openGatedThread(page, fixture.titles.buildersAlpha[0] ?? ""); + + // Collected after pairing and after the thread is open: the pairing hop + // legitimately 401s and retries its socket before a session exists. + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`)); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("codev-gate-panel")).toBeVisible({ timeout: 30_000 }); + + // Nothing widens the page. The terminal excerpt is the risk here: it is + // arbitrary-width text, and a panel that let it push the document would take + // the composer off screen at 390. + const overflow = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect( + overflow.scrollWidth, + `the document is ${overflow.scrollWidth - overflow.clientWidth}px wider than the viewport`, + ).toBe(overflow.clientWidth); + + // Every line of gate text a human has to read, at its computed size. + const sizes = await page + .getByTestId("codev-gate-panel") + .locator('p, li, [data-testid="codev-gate-question"]') + .evaluateAll((nodes) => + nodes.map((node) => Number.parseFloat(getComputedStyle(node).fontSize)), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(size).toBeGreaterThanOrEqual(MINIMUM_BODY_TEXT_PX); + } + + await settleForScreenshot(page); + await page.screenshot({ path: forkScreenshotPath("phase-8", viewport.name), fullPage: true }); + await page.setViewportSize({ width: viewport.width, height: 1400 }); + await page.waitForTimeout(400); + await page + .getByTestId("codev-gate-panel") + .screenshot({ path: forkScreenshotPath("phase-8", `${viewport.name}-panel`) }); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + + expect(consoleErrors, `console errors at ${viewport.name}`).toEqual([]); + }); +} diff --git a/packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts b/packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts new file mode 100644 index 000000000..22d222c17 --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts @@ -0,0 +1,438 @@ +/** + * Spec 250, phase 7 — the three-level tree, in t3code's own web app. + * + * ## Why this exists when `hierarchy.test.ts` already passes + * + * Those tests are about the grouping, and they are complete about it. They can + * say nothing about whether a browser draws what they returned. Every finding on + * this project so far has come from the same shape of gap — a layer testing its + * own output while the layer above it discarded the part that mattered — and + * "the sidebar renders a tree" is the last claim with no measurement under it. + * + * So this drives the FORK's web app against the FORK's server. Not a component + * harness: a component test supplies the shells itself, which is precisely the + * step whose absence is the risk. The threads here are created over the wire and + * arrive by subscription, the way they do for a user. + * + * ## Running it + * + * 1. Start the fork's web app, from the fork checkout's `apps/web`: + * T3CODE_SINGLE_ORIGIN_DEV=1 T3CODE_PORT=3811 PORT=5733 npx vp dev + * 2. Then, from this repository: + * export T3_NODE=/opt/homebrew/Cellar/node/26.4.0/bin/node + * export T3CODE_FORK_ROOT=/Users/chris/dev/t3code-codev T3_HARNESS_PORT=3811 + * npx playwright test --config playwright.spec250.config.ts + * + * The fork SERVER is started by the fixture, on an empty data directory, because + * the order assertions are meaningless over accumulated data. The WEB APP is not: + * an absent one is reported as a skip with the command to start it, never as a + * pass. "I could not tell" and "there is no tree" must not be spelled the same. + */ + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { + forkScreenshotPath, + mintPairingCredential, + seedHierarchy, + startForkStack, + stopForkStack, + type ForkStackReady, + type SeededHierarchy, +} from "./spec-250-fork-stack"; + +let stack: ForkStackReady | null = null; +let seeded: SeededHierarchy | null = null; +let unavailable: string | null = null; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + const started = await startForkStack(); + if (!started.available) { + unavailable = started.reason; + return; + } + stack = started; + seeded = await seedHierarchy(started); +}); + +test.afterAll(() => { + if (stack !== null) stopForkStack(); +}); + +/** + * Open the sidebar, paired, on a browser context that has never been paired. + * + * A fresh credential per call: the server consumes them, and a second page + * opened on a spent one lands on the pairing FORM — which looks like an empty + * sidebar and is not one. + */ +async function openSidebar(page: Page): Promise { + const ready = stack; + if (ready === null) throw new Error("unreachable: the fork stack is checked before this runs"); + const credential = await mintPairingCredential(ready); + await page.goto(`${ready.webUrl}/pair#token=${credential}`, { waitUntil: "domcontentloaded" }); + // The pairing form means the credential did not take. Say so here rather than + // letting every assertion below fail as "no tree". + await expect( + page.getByText("Enter a pairing token to start a session"), + "the browser did not pair; the sidebar assertions below would all be about the pairing form", + ).toHaveCount(0, { timeout: 30_000 }); + await revealSidebar(page); + await expect(page.getByTestId("sidebar-codev-architect").first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * Open the sidebar if this viewport keeps it off-canvas. + * + * At 390px t3code hides the sidebar behind a toggle, which is its own decision + * and not something this customization should override. A test that asserted + * against a closed sidebar would report "no tree" for a tree that is simply not + * on screen yet — the difference between a layout bug and a layout. + */ +async function revealSidebar(page: Page): Promise { + const tree = page.getByTestId("sidebar-codev-architect").first(); + try { + await tree.waitFor({ state: "visible", timeout: 5_000 }); + return; + } catch { + // Closed, or not rendered at all. The toggle distinguishes them. + } + // "Toggle main sidebar" on the chat layout, "Toggle Sidebar" on the shell's + // own rail. Matching both rather than picking one: the label a viewport + // renders is t3code's choice and changes with its own layout work. + const toggle = page.getByRole("button", { name: /toggle (main )?sidebar/i }).first(); + if ((await toggle.count()) > 0) await toggle.click(); + await tree.waitFor({ state: "visible", timeout: 20_000 }); +} + +/** + * Quiet the page down before a screenshot a human will judge. + * + * Two things otherwise sit on top of the thing under review: t3code's provider + * update toast, which is real product chrome and nothing to do with this change, + * and the mobile drawer's open transition, which a screenshot taken too early + * catches half-faded. Neither is hidden by CSS — the toast is dismissed the way + * a user dismisses it, so what is captured is a state a user can actually be in. + */ +async function settleForScreenshot(page: Page): Promise { + const dismissals = page.getByRole("button", { name: /dismiss notification/i }); + for (let index = await dismissals.count(); index > 0; index -= 1) { + await dismissals.first().click(); + } + await page.waitForTimeout(700); +} + +function subtreeFor(page: Page, architectThreadId: string): Locator { + return page.locator( + `[data-testid="sidebar-codev-architect"][data-codev-architect-thread-id="${architectThreadId}"]`, + ); +} + +/** + * The rows in a scope, named by which expected title each one carries. + * + * A sidebar card renders the project name, a status, an action and the thread + * title, and none of them is marked. Reading "the first line" therefore reads the + * PROJECT name — which is identical on every row, so an assertion built on it + * passes on any three rows at all. Matching each card against the titles this + * test is looking for asserts membership and order together, and reports an + * unmatched row as `null` rather than as a near-miss string. + */ +async function rowsNamedBy(scope: Locator, expected: readonly string[]): Promise<(string | null)[]> { + const texts = await scope.locator('[data-testid="sidebar-row-card"]').allInnerTexts(); + return texts.map((text) => expected.find((title) => text.includes(title)) ?? null); +} + +/** + * The same rows, sorted, because the order INSIDE a subtree is not this code's. + * + * `buildCodevHierarchy` preserves the order it was handed, and the sidebar hands + * it a list already sorted by recency. Asserting creation order here would be + * asserting `sortThreadsForSidebar`, which belongs to t3code and changes when the + * user picks a different sort. What this suite is about is WHICH architect owns + * each builder — a `null` in the result is a row nested here that should not be. + */ +async function rowsUnder(scope: Locator, expected: readonly string[]): Promise<(string | null)[]> { + return (await rowsNamedBy(scope, expected)).toSorted((left, right) => + left === null ? -1 : right === null ? 1 : left.localeCompare(right), + ); +} + +test.beforeEach(() => { + test.skip(unavailable !== null, unavailable ?? ""); +}); + +/** Criterion 1. */ +test("one architect and three builders render as a tree", async ({ page }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + const subtree = subtreeFor(page, fixture.architectAlpha); + await expect(subtree).toHaveCount(1); + await expect(subtree).toHaveAttribute("data-codev-builder-count", "3"); + + const builders = subtree.locator('[data-testid="sidebar-codev-builders"]'); + await expect(builders).toHaveCount(1); + expect(await rowsUnder(builders, fixture.titles.buildersAlpha)).toEqual( + [...fixture.titles.buildersAlpha].toSorted(), + ); + + // The architect is the row ABOVE its builders, not one of them. Without this + // the same assertion passes on a flat list that merely contains four rows. + const architectRow = subtree.locator('[data-testid="sidebar-row-card"]').first(); + await expect(architectRow).toContainText(fixture.titles.architectAlpha); + const architectBox = await architectRow.boundingBox(); + const builderBox = await builders.locator('[data-testid="sidebar-row-card"]').first().boundingBox(); + expect(architectBox).not.toBeNull(); + expect(builderBox).not.toBeNull(); + // Indented, and below. Two claims because either alone is satisfiable by a + // layout that is not a tree. + expect(builderBox!.x).toBeGreaterThan(architectBox!.x); + expect(builderBox!.y).toBeGreaterThan(architectBox!.y); +}); + +/** + * Criterion 1's first level, which the tree did not have until it was reviewed. + * + * "Project, architect, that architect's builders" is three levels. A tree with + * two of them and the project name repeated as a caption on every card is not + * the third level in a different form: it spends the most prominent line of + * every row on one string, eight times, and pushes the thread's own name into + * the line below it. + */ +test("the project is a heading, and its name is not repeated on every row", async ({ page }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + const heading = page.getByTestId("sidebar-codev-project-heading"); + await expect(heading).toHaveCount(1); + await expect(heading).toContainText(fixture.projectTitle); + + // Above the tree, not beside it. + const headingBox = await heading.boundingBox(); + const firstSubtreeBox = await page.getByTestId("sidebar-codev-architect").first().boundingBox(); + expect(headingBox).not.toBeNull(); + expect(firstSubtreeBox).not.toBeNull(); + expect(headingBox!.y).toBeLessThan(firstSubtreeBox!.y); + + // No row inside the tree repeats it. The rows OUTSIDE the tree still carry it + // — there it is the only thing saying which project they belong to. + const treeText = await page.getByTestId("sidebar-codev-architect").allInnerTexts(); + for (const text of treeText) { + expect(text, "a row under the project heading repeated the project name").not.toContain( + fixture.projectTitle, + ); + } + await expect( + page.getByTestId("sidebar-codev-orphan").filter({ hasText: fixture.projectTitle }), + ).toHaveCount(1); +}); + +/** + * The role, said out loud. + * + * Indent alone conveys it only while the threads are called "Architect beta" and + * "Builder alpha one". Real ones are called `builder/spir-250`, and at that point + * one level of subtle indent is the entire signal. + */ +test("the architect row says it is an architect", async ({ page }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + const subtree = subtreeFor(page, fixture.architectAlpha); + const architectRow = subtree.locator('[data-testid="sidebar-row-card"]').first(); + await expect(architectRow).toContainText("Architect"); + + // And its builders do not. A caption on every child of a labelled parent is a + // caption nobody reads, and "Architect" on a builder row would be a lie. + const builderTexts = await subtree + .locator('[data-testid="sidebar-codev-builders"] [data-testid="sidebar-row-card"]') + .allInnerTexts(); + expect(builderTexts).toHaveLength(3); + for (const text of builderTexts) { + expect(text).not.toContain("Architect"); + } +}); + +/** Criterion 2. */ +test("two architects render as two subtrees, each owning its own builders", async ({ page }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + await expect(page.getByTestId("sidebar-codev-architect")).toHaveCount(2); + + const alpha = subtreeFor(page, fixture.architectAlpha); + const beta = subtreeFor(page, fixture.architectBeta); + expect( + await rowsUnder( + alpha.locator('[data-testid="sidebar-codev-builders"]'), + fixture.titles.buildersAlpha, + ), + ).toEqual([...fixture.titles.buildersAlpha].toSorted()); + expect( + await rowsUnder(beta.locator('[data-testid="sidebar-codev-builders"]'), [ + fixture.titles.builderBeta, + ]), + ).toEqual([fixture.titles.builderBeta]); + // Neither subtree claims the other's builders. + await expect(alpha).not.toContainText(fixture.titles.builderBeta); + await expect(beta).not.toContainText(fixture.titles.architectAlpha); +}); + +/** Criterion 7. */ +test("a thread with no role appears where it always did, claimed by nothing", async ({ page }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + const plainRow = page + .locator('[data-testid="sidebar-row-card"]') + .filter({ hasText: fixture.titles.plain }); + await expect(plainRow).toHaveCount(1); + // Not inside a subtree and not inside the orphan group: the tree claims + // nothing Codev did not create. + await expect( + page.getByTestId("sidebar-codev-architect").filter({ hasText: fixture.titles.plain }), + ).toHaveCount(0); + await expect( + page.getByTestId("sidebar-codev-orphan").filter({ hasText: fixture.titles.plain }), + ).toHaveCount(0); +}); + +/** Criterion 11, rendering half. */ +test("a builder whose architect was archived is named as orphaned, with a reason", async ({ + page, +}) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + await expect(page.getByTestId("sidebar-codev-orphan-heading")).toContainText( + "Unattributed builders (1)", + ); + const orphan = page.getByTestId("sidebar-codev-orphan"); + await expect(orphan).toHaveCount(1); + await expect(orphan).toContainText(fixture.titles.orphan); + await expect(orphan).toHaveAttribute("data-codev-orphan-reason", "parent-missing"); + // The reason is on the SCREEN, not only in an attribute. A group named + // "unattributed" with no reason under each row leaves the reader with the + // same question they opened it to answer. + await expect(orphan).toContainText("its architect is not in this project"); + + // Archived means gone from the sidebar. If the ghost architect were still + // drawn, the builder would not be an orphan and this whole test would be + // measuring nothing. + await expect(page.getByText("Architect ghost")).toHaveCount(0); +}); + + +/** + * The viewports the criteria name, and what is asserted at each. + * + * Three sizes, because a sidebar tree fails differently at each: a 390px page + * fails by widening (a nested list that adds indent without giving it back), + * 1440 is the size the design is read at, and 1920 is where a layout that only + * ever centred one column starts leaving holes. + * + * The screenshots are the deliverable a human opens. The assertions are here so + * that what a human is asked to look at is a page that already passed the + * measurable half — an eyeballed 12px is a coin flip, and an eyeballed 1px of + * horizontal overflow is invisible until someone scrolls. + */ +const VIEWPORTS = [ + { name: "390", width: 390, height: 844 }, + { name: "1440x900", width: 1440, height: 900 }, + { name: "1920", width: 1920, height: 1080 }, +] as const; + +/** Text this small is not readable on a phone; 13px is the floor the criteria name. */ +const MINIMUM_BODY_TEXT_PX = 13; + +for (const viewport of VIEWPORTS) { + test(`the tree at ${viewport.name}: measured, screenshotted, and free of console errors`, async ({ + page, + }) => { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + + /** + * Errors are collected AFTER pairing, not from the first navigation. + * + * The pairing hop legitimately 401s and retries its socket: the browser has + * no session until the credential is exchanged. Counting those would make + * "zero console errors" unachievable for a reason that has nothing to do + * with the sidebar, and the usual repair — an allow-list of expected errors + * — is how a real one gets waved through later. + */ + await openSidebar(page); + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`)); + await page.reload({ waitUntil: "domcontentloaded" }); + await revealSidebar(page); + await expect(page.getByTestId("sidebar-codev-architect").first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("sidebar-codev-orphan")).toHaveCount(1); + + // Nothing widens the page. At 390 this is the assertion that catches an + // indent added without being given back; at every width it catches a nested + // list that grew past its container. + const overflow = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect( + overflow.scrollWidth, + `the document is ${overflow.scrollWidth - overflow.clientWidth}px wider than the viewport`, + ).toBe(overflow.clientWidth); + + // Every thread title in the tree, at its computed size. Reading one row + // would pass a tree whose builders were shrunk to fit the indent. + // `text-sm` is the thread TITLE. The project label and the status beside it + // are `text-xs` secondary labels, and measuring those would be measuring + // t3code's own type scale rather than whether this tree kept it. + const titleSizes = await page + .getByTestId("sidebar-codev-architect") + .locator('[data-testid="sidebar-row-card"] span.text-sm') + .evaluateAll((nodes) => + nodes.map((node) => Number.parseFloat(getComputedStyle(node).fontSize)), + ); + expect(titleSizes.length).toBeGreaterThan(0); + for (const size of titleSizes) { + expect(size).toBeGreaterThanOrEqual(MINIMUM_BODY_TEXT_PX); + } + + await settleForScreenshot(page); + await page.screenshot({ path: forkScreenshotPath("phase-7", viewport.name), fullPage: true }); + // The sidebar is its own scroll container under a sticky footer, so a + // full-page shot of a 900px window clips the tail of a longer list — and the + // tail is the orphan group with its reason line, which is the part a + // reviewer most needs to see. This second shot is the LIST at its full + // height, taken at the SAME WIDTH in a tall window so nothing is scrolled + // out or covered. The width is what the responsive claim is about; the + // height here is only so the picture is complete. + await page.setViewportSize({ width: viewport.width, height: 1400 }); + await page.waitForTimeout(400); + await page + .getByTestId("sidebar-codev-orphan-heading") + .locator("xpath=ancestor::ul[1]") + .screenshot({ path: forkScreenshotPath("phase-7", `${viewport.name}-tree`) }); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + + // Listed, not counted: a bare "expected 0, got 3" sends the reader back to + // the browser to find out which three. + expect(consoleErrors, `console errors at ${viewport.name}`).toEqual([]); + }); +} diff --git a/packages/codev/src/__tests__/e2e/spec-250-same-origin.ts b/packages/codev/src/__tests__/e2e/spec-250-same-origin.ts new file mode 100644 index 000000000..133fcb7dc --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-same-origin.ts @@ -0,0 +1,39 @@ +/** + * Spec 250, phase 10 — the same-origin comparison, in its own module so a unit + * test can reach it. + * + * It lived inside the Playwright spec, where nothing could test it. That matters + * here more than usual: this function IS the phase's central security assertion, + * and the review found it silently unable to fail on a colliding ephemeral port. + * A predicate that decides whether a security claim passed should not be the one + * piece of the suite with no test of its own. + */ + +/** + * Which of these requests were CROSS-ORIGIN — compared as parsed origins, never + * as string prefixes. + * + * Review finding, and the numbers are why it was worth fixing rather than + * noting. `url.startsWith(origin)` is a PREFIX match, and the origin here is a + * fixed `http://localhost:5733` while the agent host binds an ephemeral port via + * `listen(0)`. So `http://localhost:57330` through `:57339` prefix-match and + * would be filtered as same-origin — ten ports inside macOS's ephemeral range, + * roughly 0.06% of runs in which a genuinely direct browser-to-agent request + * would have been counted as same-origin and the assertion would have passed. + * + * A rare false PASS on the phase's central security claim is worse than a common + * one: it makes the test look reliable while it is not, and 0.06% is exactly the + * rate at which nobody ever sees it fail. + * + * Non-http schemes are exempt as a CLASS rather than by name. `data:` and + * `blob:` are not requests to another origin at all, and `new URL("data:…").origin` + * is the string `"null"` — so comparing them by origin would report a false + * FAILURE. Excluding them by scheme covers `about:` and anything else a browser + * invents, which naming them one at a time does not. + */ +export function crossOrigin(requests: readonly string[], origin: string): string[] { + return requests.filter((url) => { + if (!/^https?:/i.test(url)) return false; + return new URL(url).origin !== origin; + }); +} diff --git a/packages/codev/src/__tests__/e2e/spec-250-tiling.spec.ts b/packages/codev/src/__tests__/e2e/spec-250-tiling.spec.ts new file mode 100644 index 000000000..64b12933e --- /dev/null +++ b/packages/codev/src/__tests__/e2e/spec-250-tiling.spec.ts @@ -0,0 +1,369 @@ +/** + * Spec 250, phase 9 — the tiling, measured from the rendered page. + * + * ## Why this cannot be a unit test + * + * `layout.test.ts` proves the arithmetic: seven panes in 1664px of content get + * four columns. It cannot prove the pane a browser actually draws is 340px wide + * INSIDE t3code's chrome, because the number that decides it — the grid + * container's width behind a 232px sidebar — is not a number any unit test has. + * That is why criteria 5 and 5b say "measured from the rendered page", and it is + * why the constants were re-measured rather than ported: `apps/client` owned the + * whole viewport and this grid does not. + * + * ## Criterion 5b is the one that matters + * + * Both the fewest-rows rule and the near-square rule give three columns for six + * panes at 1440, so criterion 5 alone cannot tell them apart. Seven panes at + * 1920 can: fewest-rows gives 4x2, near-square gives 3x3. The fixture seeds + * exactly seven for that reason. + * + * Running it: see `spec-250-hierarchy.spec.ts` — same stack, same commands. + */ + +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { + forkScreenshotPath, + mintPairingCredential, + seedTiling, + startForkStack, + stopForkStack, + type ForkStackReady, + type SeededTiling, +} from "./spec-250-fork-stack"; + +let stack: ForkStackReady | null = null; +let seeded: SeededTiling | null = null; +let unavailable: string | null = null; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + const started = await startForkStack(); + if (!started.available) { + unavailable = started.reason; + return; + } + stack = started; + seeded = await seedTiling(started); +}); + +test.afterAll(() => { + if (stack !== null) stopForkStack(); +}); + +test.beforeEach(() => { + test.skip(unavailable !== null, unavailable ?? ""); +}); + +/** Criterion 5's floors, restated here so the spec does not import the fork. */ +const MIN_PANE_W = 340; +const MIN_PANE_H = 240; +const MIN_BODY_PX = 13; + +function ready(): { stack: ForkStackReady; seeded: SeededTiling } { + if (stack === null || seeded === null) { + throw new Error("unreachable: the fork stack is checked before this runs"); + } + return { stack, seeded }; +} + +/** Pair a fresh context and land on the builder grid. */ +async function openGrid(page: Page): Promise { + const { stack: live } = ready(); + const credential = await mintPairingCredential(live); + await page.goto(`${live.webUrl}/pair#token=${credential}`, { waitUntil: "domcontentloaded" }); + await expect( + page.getByText("Enter a pairing token to start a session"), + "the browser did not pair; every measurement below would be of the pairing form", + ).toHaveCount(0, { timeout: 30_000 }); + /* + * Through the sidebar link, not by typing the URL. + * + * Review finding: the route existed and nothing linked to it. A test that + * `goto`s the path proves the route renders and says nothing about whether a + * user can find it — which is how the grid shipped unreachable. + */ + const link = page.getByTestId("sidebar-codev-builders-link"); + const toggle = page.getByRole("button", { name: /toggle (main )?sidebar/i }).first(); + let openedSidebar = false; + try { + await link.waitFor({ state: "visible", timeout: 5_000 }); + } catch { + // At 390 the sidebar is off-canvas, so the way in is behind the toggle — + // which is still a way in, and the assertion is that one exists. + if ((await toggle.count()) > 0) { + await toggle.click(); + openedSidebar = true; + } + await link.waitFor({ state: "visible", timeout: 20_000 }); + } + // t3code's provider-update toast lands over the sidebar at 390 and intercepts + // the click. Dismissed the way a user dismisses it, rather than forced. + const dismissals = page.getByRole("button", { name: /dismiss notification/i }); + for (let index = await dismissals.count(); index > 0; index -= 1) { + await dismissals.first().click(); + } + await link.click(); + // On a phone the drawer stays open over the page it just navigated to, so + // close it again — which is what a user does, and what puts the grid on + // screen to be measured. + if (openedSidebar) await toggle.click(); + await expect(page.getByTestId("codev-builder-pane").first()).toBeVisible({ timeout: 30_000 }); +} + +function panes(page: Page): Locator { + return page.getByTestId("codev-builder-pane"); +} + +/** + * The distinct column positions the browser actually laid out. + * + * The grid also reports its column count in `data-codev-grid-columns`, and an + * assertion on that attribute alone would be asking the component to confirm its + * own arithmetic. Counting distinct x-positions of the rendered boxes asks the + * BROWSER. Both are checked, and they have to agree. + */ +async function renderedColumns(page: Page): Promise { + const boxes = await panes(page).evaluateAll((nodes) => + nodes.map((node) => Math.round(node.getBoundingClientRect().x)), + ); + return new Set(boxes).size; +} + +/** Criterion 5. */ +test("six builders are watchable at 1440x900, every pane over the floor", async ({ page }) => { + const { seeded: fixture } = ready(); + await page.setViewportSize({ width: 1440, height: 900 }); + await openGrid(page); + + // SIX panes at 1440, not seven — criterion 4b. Three columns and seven items + // is 3 + 3 + 1: one lonely card beside two empty slots. The architect is on a + // strip below the grid instead. + await expect(panes(page)).toHaveCount(6); + for (const title of fixture.builderTitles) { + await expect(panes(page).filter({ hasText: title })).toHaveCount(1); + } + await expect(page.getByTestId("codev-architect-strip")).toHaveCount(1); + await expect(page.getByTestId("codev-architect-strip")).toContainText(fixture.architectTitle); + await expect(page.getByTestId("codev-builder-grid")).toHaveAttribute( + "data-codev-architect-placement", + "strip", + ); + + const boxes = await panes(page).evaluateAll((nodes) => + nodes.map((node) => { + const box = node.getBoundingClientRect(); + return { width: box.width, height: box.height }; + }), + ); + expect(boxes).toHaveLength(6); + for (const box of boxes) { + expect(box.width, "a pane fell under the 340px floor").toBeGreaterThanOrEqual(MIN_PANE_W); + expect(box.height, "a pane fell under the 240px floor").toBeGreaterThanOrEqual(MIN_PANE_H); + } + + // Three columns at 1440 — the count both rules agree on, asserted so a + // regression that broke BOTH of them is caught here rather than only at 1920. + // Six panes in three columns is a clean 3x2, which is the shape 4b protects. + expect(await renderedColumns(page)).toBe(3); + const rows = await panes(page).evaluateAll((nodes) => + nodes.map((node) => Math.round(node.getBoundingClientRect().y)), + ); + expect(new Set(rows).size).toBe(2); + await expect(page.getByTestId("codev-builder-grid")).toHaveAttribute( + "data-codev-grid-columns", + "3", + ); + + // Body text. Every text node a human reads in a pane, not one sampled line. + const sizes = await panes(page) + .locator("p, span, div") + .evaluateAll((nodes) => + nodes + .filter((node) => (node.textContent ?? "").trim().length > 0) + .map((node) => Number.parseFloat(getComputedStyle(node).fontSize)), + ); + expect(sizes.length).toBeGreaterThan(0); + for (const size of sizes) { + expect(size).toBeGreaterThanOrEqual(MIN_BODY_PX); + } +}); + +/** + * The role prefix is the pane's only structural signal of what it is. + * + * The sidebar can fall back on indent and a rail; a grid of equal tiles cannot, + * and the case that needs it most is several architects taking tiles beside the + * builders rather than one taking a strip. So the prefix must survive the + * NARROWEST pane, which is the pane at the 340px floor — the sidebar's own role + * caption truncates to "Archit…" under exactly that pressure. + */ +test("every pane names its role in full, even at the narrowest width", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openGrid(page); + + const roles = page.getByTestId("codev-pane-role"); + await expect(roles).toHaveCount(6); + + // Not truncated: an element whose content is wider than its box is one the + // browser is clipping, and `text-overflow: ellipsis` makes that invisible to + // a text assertion — `toContainText("builder/")` passes on "buil…" because the + // DOM still holds the whole string. + const clipped = await roles.evaluateAll((nodes) => + nodes + .map((node) => ({ text: node.textContent ?? "", over: node.scrollWidth - node.clientWidth })) + .filter((entry) => entry.over > 0), + ); + expect(clipped, "a pane's role prefix was clipped").toEqual([]); + + for (const text of await roles.allInnerTexts()) { + expect(text.trim()).toBe("builder/"); + } + + // And the expanded architect, which is the same pane component and the one + // whose prefix is doing the most work. + await page.getByTestId("codev-architect-strip-toggle").click(); + const architectRole = page + .getByTestId("codev-architect-strip") + .getByTestId("codev-pane-role"); + await expect(architectRole).toHaveText("architect/"); + expect( + await architectRole.evaluate((node) => node.scrollWidth - node.clientWidth), + ).toBeLessThanOrEqual(0); +}); + +/** + * CRITERION 5b. The case criterion 5 cannot see. + * + * Fewest-rows: 4x2. Near-square: 3x3 — three columns of a five-column-wide + * screen, with a last row holding one tile beside two tiles' worth of nothing. + */ +test("seven panes at 1920 tile 4x2, not 3x3", async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await openGrid(page); + // Seven here, because four columns fit and 4 + 3 is not ragged — criterion 4b + // offers the architect an equal tile exactly where that is true. + await expect(panes(page)).toHaveCount(7); + await expect(page.getByTestId("codev-architect-strip")).toHaveCount(0); + await expect(page.getByTestId("codev-builder-grid")).toHaveAttribute( + "data-codev-architect-placement", + "tile", + ); + + expect(await renderedColumns(page), "seven panes did not lay out in four columns").toBe(4); + await expect(page.getByTestId("codev-builder-grid")).toHaveAttribute( + "data-codev-grid-columns", + "4", + ); + + // Two rows, said as its own claim: four columns and three rows is not a thing + // seven panes can do, but asserting it means a future rule that produced one + // fails here loudly rather than passing the column check by accident. + const rows = await panes(page).evaluateAll((nodes) => + nodes.map((node) => Math.round(node.getBoundingClientRect().y)), + ); + expect(new Set(rows).size).toBe(2); + + for (const box of await panes(page).evaluateAll((nodes) => + nodes.map((node) => node.getBoundingClientRect().width), + )) { + expect(box).toBeGreaterThanOrEqual(MIN_PANE_W); + } +}); + +/** The 390px claim: page, do not shrink, and do not widen the document. */ +test("the grid pages at 390 and nothing scrolls sideways", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openGrid(page); + + const grid = page.getByTestId("codev-builder-grid"); + await expect(grid).toHaveAttribute("data-codev-grid-mode", "paged"); + await expect(grid).toHaveAttribute("data-codev-grid-columns", "1"); + + // Paged, not shrunk: the panes on screen are a page of them, and the rest are + // behind the pager rather than squeezed in beside these. + // Six builders on a paged grid: three pages of two, with the architect on its + // strip below rather than taking a page slot of its own. + await expect(panes(page)).toHaveCount(2); + await expect(page.getByTestId("codev-architect-strip")).toHaveCount(1); + await expect(page.getByTestId("codev-builder-grid-page-label")).toContainText("Page 1 of 3"); + + const overflow = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect( + overflow.scrollWidth, + `the document is ${overflow.scrollWidth - overflow.clientWidth}px wider than the viewport`, + ).toBe(overflow.clientWidth); + + // And the pager works, which is the difference between paging and hiding. + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByTestId("codev-builder-grid-page-label")).toContainText("Page 2 of 3"); + await expect(panes(page)).toHaveCount(2); +}); + +/** + * Criterion 4b's other half: the strip is not a demotion, it expands. + * + * "It gets a persistent strip below the grid showing status, and expands to a + * full pane on demand." A strip that could not expand would be hiding the + * architect rather than placing it. + */ +test("the architect strip expands to a full pane and back", async ({ page }) => { + const { seeded: fixture } = ready(); + await page.setViewportSize({ width: 1440, height: 900 }); + await openGrid(page); + + const strip = page.getByTestId("codev-architect-strip"); + await expect(strip).toHaveAttribute("data-codev-architect-expanded", "false"); + // Collapsed, it is a line: status and identity, no pane. + await expect(strip.getByTestId("codev-builder-pane")).toHaveCount(0); + await expect(strip).toContainText(fixture.architectTitle); + + await page.getByTestId("codev-architect-strip-toggle").click(); + await expect(strip).toHaveAttribute("data-codev-architect-expanded", "true"); + await expect(strip.getByTestId("codev-builder-pane")).toHaveCount(1); + // And the builders keep their grid — expanding the architect is not a mode + // that takes the screen away from what it was watching. + await expect(panes(page)).toHaveCount(7); + expect(await renderedColumns(page)).toBe(3); + + await page.getByTestId("codev-architect-strip-toggle").click(); + await expect(strip).toHaveAttribute("data-codev-architect-expanded", "false"); + await expect(panes(page)).toHaveCount(6); +}); + +const VIEWPORTS = [ + { name: "390", width: 390, height: 844 }, + { name: "1440x900", width: 1440, height: 900 }, + { name: "1920", width: 1920, height: 1080 }, +] as const; + +for (const viewport of VIEWPORTS) { + test(`the grid at ${viewport.name}: screenshotted and free of console errors`, async ({ + page, + }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await openGrid(page); + + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`)); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(panes(page).first()).toBeVisible({ timeout: 30_000 }); + + const dismissals = page.getByRole("button", { name: /dismiss notification/i }); + for (let index = await dismissals.count(); index > 0; index -= 1) { + await dismissals.first().click(); + } + await page.waitForTimeout(700); + await page.screenshot({ path: forkScreenshotPath("phase-9", viewport.name), fullPage: true }); + + expect(consoleErrors, `console errors at ${viewport.name}`).toEqual([]); + }); +} diff --git a/packages/codev/src/__tests__/spec-146-t3-contract.test.ts b/packages/codev/src/__tests__/spec-146-t3-contract.test.ts index 53e431731..2b23b7a18 100644 --- a/packages/codev/src/__tests__/spec-146-t3-contract.test.ts +++ b/packages/codev/src/__tests__/spec-146-t3-contract.test.ts @@ -43,6 +43,70 @@ const readSchemas = () => readJson(join(generated, 'schema.json')).schemas as Re const T3_ROOT = process.env.T3CODE_ROOT ?? ''; const HAS_CHECKOUT = T3_ROOT !== '' && existsSync(join(T3_ROOT, 'packages', 'contracts', 'src')); +/** + * Spec 250 adds a second checkout, so `T3CODE_ROOT` alone no longer says which + * tree a live assertion is about. `T3_ROOT` keeps its spec 146 meaning — the + * UPSTREAM clone at `upstreamBase` — and the fork gets its own variable and its + * own gate. Two questions, two skips: a run with only the upstream checkout must + * report the fork suite as skipped rather than passing it by comparing upstream + * to itself. + */ +const T3_FORK_ROOT = process.env.T3CODE_FORK_ROOT ?? ''; +const HAS_FORK_CHECKOUT = + T3_FORK_ROOT !== '' && existsSync(join(T3_FORK_ROOT, 'packages', 'contracts', 'src')); + +/** + * Is the fork checkout ON the commit the artifacts were generated from? + * + * Having a fork checkout is not the same question. `pin.commit` means "the + * vendored contract came from this commit" and only regeneration moves it, so + * between the fork's first customization commit and that regeneration the + * checkout is legitimately AHEAD — and hashing its files against artifacts + * generated from an older commit compares two different trees. + * + * That window is three plan phases long. Letting the suite fail through it would + * train everyone to ignore a red suite, which is the failure the whole + * ahead-vs-wrong distinction exists to prevent. So it SKIPS, and the suite name + * says which of the two reasons it skipped for — never silently passing. + */ +const forkHead = (() => { + if (!HAS_FORK_CHECKOUT) return null; + const result = spawnSync('git', ['-C', T3_FORK_ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : null; +})(); +const FORK_AT_CONTRACT = + forkHead !== null && forkHead === readJson(join(t3Root, 'pin.json')).commit; +/** + * WHICH way the fork differs, not merely that it does. + * + * "Ahead" is the expected state until phase 5; behind or unrelated is a real + * problem someone has to look at. Reporting all three as "ahead" would let a + * genuinely broken checkout hide inside the tolerated case for three phases. + */ +const forkRelation = (() => { + if (forkHead === null) return null; + const contract = readJson(join(t3Root, 'pin.json')).commit; + if (forkHead === contract) return 'at'; + const ancestor = (a: string, b: string) => + spawnSync('git', ['-C', T3_FORK_ROOT, 'merge-base', '--is-ancestor', a, b]).status === 0; + if (ancestor(contract, forkHead)) return 'ahead'; + if (ancestor(forkHead, contract)) return 'behind'; + return 'unrelated'; +})(); +const forkSkipReason = !HAS_FORK_CHECKOUT + ? `no fork checkout at ${T3_FORK_ROOT || '$T3CODE_FORK_ROOT (unset)'}` + : forkHead === null + ? `could not read HEAD of ${T3_FORK_ROOT}` + : forkRelation === 'ahead' + ? `fork is at ${forkHead.slice(0, 12)}, ahead of contract commit ` + + `${readJson(join(t3Root, 'pin.json')).commit.slice(0, 12)} (expected until phase 5 regenerates)` + : forkRelation === 'behind' + ? `fork is at ${forkHead.slice(0, 12)}, BEHIND contract commit ` + + `${readJson(join(t3Root, 'pin.json')).commit.slice(0, 12)} — this is not the expected ` + + `pre-phase-5 state and wants looking at` + : `fork is at ${forkHead.slice(0, 12)}, UNRELATED to contract commit ` + + `${readJson(join(t3Root, 'pin.json')).commit.slice(0, 12)} — no ancestry either way`; + describe('spec 146: packages/types stays dependency-free', () => { it('declares no runtime dependencies', () => { const pkg = readJson(join(typesRoot, 'package.json')); @@ -140,11 +204,71 @@ describe('spec 146: the emitter is lossy, and says so', () => { * is none, so its absence is legible in the run output instead of disappearing * into a green unit run. */ -describe.skipIf(!HAS_CHECKOUT)(`spec 146 [live: needs t3code checkout at ${T3_ROOT || '$T3CODE_ROOT (unset)'}]`, () => { - it('hashes match the pinned checkout', () => { +describe.skipIf(!HAS_CHECKOUT)(`spec 146 [live: needs upstream t3code checkout at ${T3_ROOT || '$T3CODE_ROOT (unset)'}]`, () => { + /** + * Spec 250: this compares the UPSTREAM section against the UPSTREAM clone. + * + * Before the fork existed, `hashes.files` and this checkout were the same tree, + * so one assertion covered both. `hashes.files` is now the fork's closure, and + * checking it here would start failing the moment we customize anything — and, + * worse, would report our own deliberate change as upstream drift. + */ + it('upstream hashes match the upstream checkout at upstreamBase', () => { const pin = readJson(join(t3Root, 'pin.json')); const contracts = join(T3_ROOT, pin.contractsRoot); const hashes = readJson(join(generated, 'source-hash.json')); + + expect( + hashes.upstream?.available, + `source-hash.json records no upstream measurement (${hashes.upstream?.reason ?? 'no reason given'}); ` + + 'regenerate with the upstream clone present rather than treating an unmeasured section as a match', + ).toBe(true); + expect(hashes.upstream.commit).toBe(pin.upstreamBase); + + for (const [file, expected] of Object.entries(hashes.upstream.files)) { + const actual = createHash('sha256').update(readFileSync(join(contracts, file))).digest('hex'); + expect(actual, `${file} drifted from the recorded upstream hash`).toBe(expected); + } + }); +}); + +/** + * The gate above closed itself for three phases and had to reopen on its own. + * + * `FORK_AT_CONTRACT` is fork HEAD === `pin.commit`. Through phases 2-4 that was + * false by design and the fork-hash suite skipped. Phase 5 moved `pin.commit` + * onto the fork head, so it is true again and the suite runs — without anyone + * editing the gate. + * + * That is worth an assertion because the failure mode is silent in the wrong + * direction: a regeneration that moved `pin.commit` somewhere the checkout is not + * would leave the suite skipping forever, reported as a skip reason nobody reads, + * while `contractSource` claimed the contract was fork-sourced. This test is NOT + * inside the gated block — a gate cannot assert that it opened. + */ +describe('spec 250: the fork-hash gate reopens once the contract is fork-sourced', () => { + it.skipIf(!HAS_FORK_CHECKOUT)('is open, not skipping, now that pin.commit is the fork head', () => { + const pin = readJson(join(t3Root, 'pin.json')); + if (pin.contractSource !== 'fork') { + // Phases 1-4. Ahead is the expected state and the gate is correctly shut. + expect(forkRelation).toBe('ahead'); + return; + } + expect( + forkRelation, + `the contract says it was generated from ${pin.commit.slice(0, 12)} but the fork checkout is ` + + `${forkRelation} that commit, so the hash suite would skip forever while claiming to be fork-sourced`, + ).toBe('at'); + expect(FORK_AT_CONTRACT).toBe(true); + }); +}); + +describe.skipIf(!FORK_AT_CONTRACT)(`spec 250 [live: needs the fork checkout ON pin.commit — ${forkSkipReason}]`, () => { + it('generated hashes match the fork checkout the artifacts came from', () => { + const pin = readJson(join(t3Root, 'pin.json')); + const contracts = join(T3_FORK_ROOT, pin.contractsRoot); + const hashes = readJson(join(generated, 'source-hash.json')); + expect(hashes.commit).toBe(pin.commit); for (const [file, expected] of Object.entries(hashes.files)) { const actual = createHash('sha256').update(readFileSync(join(contracts, file))).digest('hex'); expect(actual, `${file} drifted from the pinned hash`).toBe(expected); @@ -228,15 +352,51 @@ describe('spec 146: the harness criterion that gates Phase 2', () => { } }); - it('was run against the commit this repo pins', () => { + /** + * Spec 250 phase 5 re-scoped this, deliberately. + * + * It used to assert `evidence.pinnedCommit === pin.commit`, which held only + * while the two identities were equal. Phase 5 regenerates the vendored + * contract from the fork and moves `pin.commit` onto the fork head — but this + * evidence describes the UPSTREAM harness starting the UPSTREAM server from the + * read-only upstream clone. The commit it should be checked against is + * therefore `pin.upstreamBase`. + * + * Re-collecting against the fork would be the wrong fix. It would silently + * change what the evidence is evidence OF, and spec 146's criteria about the + * pinned harness would stop meaning what they said while staying green. + * + * The collector's field was RENAMED at the same time (`pinnedCommit` -> + * `upstreamCommit`), so evidence written under the old meaning cannot be read + * as though it were written under the new one. The absent-field assertion below + * is what makes that true: without it, stale evidence carrying the old key + * would arrive as `undefined` and only the rename's own test would notice. + */ + it('was run against the upstream commit, which is no longer pin.commit', () => { const pin = readJson(join(t3Root, 'pin.json')); - expect(evidence.pinnedCommit).toBe(pin.commit); + expect( + evidence.pinnedCommit, + 'this evidence predates the pinnedCommit -> upstreamCommit rename; re-collect it with ' + + 'tools/t3-server/smoke.mjs rather than reading the old key', + ).toBeUndefined(); + expect(evidence.upstreamCommit).toBe(pin.upstreamBase); expect(evidence.pinnedCliVersion).toBe(pin.cliVersion); for (const run of evidence.runs) { expect(run.serverRuntime.cliVersion).toBe(pin.cliVersion); } }); + /** + * The two identities have diverged, so "asserted against upstreamBase" is a + * real constraint now rather than a restatement of the previous one. If they + * were still equal the assertion above would pass either way and this suite + * would be claiming a distinction it had not tested. + */ + it('is checking a commit that actually differs from pin.commit', () => { + const pin = readJson(join(t3Root, 'pin.json')); + expect(pin.commit).not.toBe(pin.upstreamBase); + }); + it('passed every run', () => { expect(evidence.allRunsPassed).toBe(true); }); @@ -286,7 +446,10 @@ describe('spec 146: tooling distinguishes "nothing to do" from "it failed"', () join(repoRoot, 'tools', 't3-codegen', 'classify-churn.mjs'), 'utf8', ); - const emptyBranch = /no commits touch the closure in that range[\s\S]{0,400}?process\.exit\((\d)\)/.exec(src); + // Spec 250 gave the empty result a mode-specific signal, so the message names + // the range rather than saying "that range". The property under test is + // unchanged: an empty range exits 0. + const emptyBranch = /no commits touch the closure in \$\{rangeSpec\}[\s\S]{0,600}?process\.exit\((\d)\)/.exec(src); expect(emptyBranch, 'the empty-range branch should still exist').not.toBeNull(); expect(emptyBranch?.[1], 'an empty range is not a failure').toBe('0'); }); @@ -294,8 +457,17 @@ describe('spec 146: tooling distinguishes "nothing to do" from "it failed"', () it('the harness keeps a third exit code for "could not determine"', () => { // 0 verified, 1 mismatch, 3 could-not-determine. Collapsing 3 into either of // the others is what makes a missing checkout read as a passing check. + // + // Spec 250 moved the constants into `tools/t3-fork/identities.mjs` so both + // checkout identities spell them the same way. The assertion follows them + // there and additionally pins that the harness uses the shared definition + // rather than redeclaring its own. + const shared = readFileSync(join(repoRoot, 'tools', 't3-fork', 'identities.mjs'), 'utf8'); + expect(shared).toMatch(/export const UNDETERMINED = 3/); + expect(shared).toMatch(/export const MISMATCH = 1/); + const src = readFileSync(join(repoRoot, 'tools', 't3-server', 't3-server.mjs'), 'utf8'); - expect(src).toMatch(/const UNDETERMINED = 3/); + expect(src).toMatch(/UNDETERMINED[\s\S]{0,120}from '\.\.\/t3-fork\/identities\.mjs'/); expect(src).toMatch(/die\(\s*UNDETERMINED/); }); @@ -357,7 +529,13 @@ describe('spec 146: tooling distinguishes "nothing to do" from "it failed"', () const src = readFileSync(harness, 'utf8'); // `start` still wipes by default: the phase-1 cold-start evidence is only // evidence if each run begins with an empty database. - expect(src).toContain('function start({ keepData = false } = {})'); + // + // Matched on the DEFAULT rather than on the whole parameter list. Spec 250 + // phase 6 added an `identity` parameter for `start-fork`, and this assertion + // — which is about `keepData` defaulting to false — failed on a signature + // change that left its subject untouched. A source assertion should break + // when its claim stops holding, not when a neighbouring word appears. + expect(src).toMatch(/function start\(\{ keepData = false[,}]/); expect(src).toContain('start({ keepData: true })'); // And a restart exits "could not determine" rather than quietly cold-starting, diff --git a/packages/codev/src/__tests__/spec-250-drill-closure.test.ts b/packages/codev/src/__tests__/spec-250-drill-closure.test.ts new file mode 100644 index 000000000..2d8ce36b9 --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-drill-closure.test.ts @@ -0,0 +1,105 @@ +/** + * Spec 250 phase 11 — the drill's measurability guard, reached directly. + * + * `rebase-drill.mjs` is a script: importing it runs a drill against two real + * checkouts. So every branch inside it is covered only by whatever the last real + * run happened to take — which is exactly the wrong coverage for a guard that + * exists to fire on cases a normal run never reaches. + * + * The case that matters is a `git merge` that neither completes nor conflicts. + * The worktree is then the UNMERGED fork, and the closure would be compared to + * the contract generated from that same fork: `moved: []`, on every run, forever, + * looking exactly like good news. A guard that only asks "did the closure + * conflict" is vacuously satisfied there — zero conflicts, because there was no + * merge to conflict. + * + * **The order of the two checks is NOT asserted here, and the comment where the + * assertion would go explains why.** An earlier draft of this header said it was; + * that sentence outlived the test it described by one commit, which is the + * iteration 1 defect in miniature — a comment claiming a check that is not + * there. The opencode lane caught it. + */ + +import { describe, expect, it } from 'vitest'; + +import { closureMeasurability } from '../../../../tools/t3-fork/drill-closure.mjs'; + +describe('spec 250 phase 11: the drill closure measurability guard', () => { + it('measures when the merge produced a tree and the closure came through clean', () => { + expect(closureMeasurability({ + mergeOk: false, + conflictedFiles: ['apps/server/src/server.test.ts'], + closureConflicts: [], + })).toEqual({ measurable: true }); + }); + + it('measures when the merge completed outright', () => { + expect(closureMeasurability({ + mergeOk: true, + conflictedFiles: [], + closureConflicts: [], + })).toEqual({ measurable: true }); + }); + + /** + * THE BRANCH NO REAL RUN REACHES. + * + * `mergeOk: false` with nothing conflicted is a merge that did not happen. Note + * that `closureConflicts` is empty here, which is what makes this dangerous: + * the closure question answers "clean" for the wrong reason. + */ + it('refuses when the merge neither completed nor conflicted', () => { + const verdict = closureMeasurability({ + mergeOk: false, + conflictedFiles: [], + closureConflicts: [], + gitSaid: 'Already up to date.\nfatal: something else\nthird line\nfourth line', + }); + expect(verdict.measurable).toBe(false); + expect(verdict.reason).toContain('neither completed nor conflicted'); + expect(verdict.reason).toContain('compare the fork to itself'); + // git's own words are carried, capped at three lines so a wall of output + // cannot bury the reason. + expect(verdict.reason).toContain('Already up to date.'); + expect(verdict.reason).not.toContain('fourth line'); + }); + + /** A refusal must still be a refusal when git said nothing at all. */ + it('refuses without git output, and does not emit a dangling "git said"', () => { + const verdict = closureMeasurability({ + mergeOk: false, + conflictedFiles: [], + closureConflicts: [], + }); + expect(verdict.measurable).toBe(false); + expect(verdict.reason).not.toContain('git said'); + }); + + it('refuses when the generator\'s own source conflicted, and names the files', () => { + const verdict = closureMeasurability({ + mergeOk: false, + conflictedFiles: ['packages/contracts/src/orchestration.ts', 'apps/web/src/x.ts'], + closureConflicts: ['packages/contracts/src/orchestration.ts'], + }); + expect(verdict.measurable).toBe(false); + expect(verdict.reason).toContain('packages/contracts/src/orchestration.ts'); + expect(verdict.reason).toContain('no single'); + }); + + /* + * NO ORDER TEST, AND THE REASON IS THE INTERESTING PART. + * + * A first draft asserted that the no-merge check runs before the closure- + * conflict check. Swapping the two in the module left it passing, which is the + * signal that it was not a test: `closureConflicts` is a subset of + * `conflictedFiles`, so a non-empty closure conflict implies a non-empty + * conflict list, and the no-merge branch requires that list to be EMPTY. The + * two conditions cannot both hold for well-formed input, so there is no order + * to assert and no input that could distinguish one ordering from the other. + * + * The comment in the module still explains why the no-merge check is written + * first — it reads as the safer arrangement and it is free — but "reads safer" + * is not a property, and a test that cannot fail claiming otherwise is worse + * than no test. + */ +}); diff --git a/packages/codev/src/__tests__/spec-250-evidence-collector.test.ts b/packages/codev/src/__tests__/spec-250-evidence-collector.test.ts new file mode 100644 index 000000000..072001455 --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-evidence-collector.test.ts @@ -0,0 +1,189 @@ +/** + * Spec 250 phase 11 — the evidence collector, and the two ways it must refuse. + * + * The collector fills the numbers in `250-acceptance-evidence.md` from the + * machine-readable runs, because a hand-typed "3 of 35 files conflict" is true + * on the day it is typed and silently wrong after the next drill — and it is the + * sentence a reader will quote. + * + * What is asserted here is not that it can fill a table. It is that its two + * refusals are DIFFERENT, and that neither is spelled like success: + * + * exit 3 the runs could not be read, or describe a different fork. Nothing + * is claimed about what they would have said. + * exit 1 the runs are fine and the committed evidence has drifted from them. + * + * Collapsing those would make "the evidence is stale" and "I could not check" + * the same answer, on the file whose whole job is to be trustworthy. + */ + +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); +const collector = join(repoRoot, 'tools', 't3-server', 'collect-spec-250-evidence.mjs'); + +/** + * Everything the collector reads, relative to the root it derives from its own + * location. Kept as one list because a scratch root that is missing one of them + * makes the collector exit 3 for `MISSING_RUN` — the right code for the wrong + * reason, which would let a broken fixture masquerade as the refusal under test. + */ +const COLLECTOR_INPUTS = [ + 'codev/resources/250-acceptance-evidence.md', + 'codev/research/250-rebase-drill.json', + 'codev/research/250-criterion-8b-evidence.json', + 'codev/research/250-hierarchy-wire-evidence.json', + 'codev/research/250-upstream-movement.json', + 'packages/types/src/t3/pin.json', +] as const; + +const run = (root: string, ...args: string[]) => + spawnSync( + process.execPath, + [join(root, 'tools', 't3-server', 'collect-spec-250-evidence.mjs'), ...args], + { encoding: 'utf8', cwd: root }, + ); + +/** + * A throwaway tree the collector can be made to fail in. + * + * The five refusal tests below all work by DAMAGING an input, and the earlier + * version of this file damaged the committed file in place and restored it in a + * `finally`. Two things were wrong with that. A killed run left a mutated + * tracked file and a stray `.spec250-test-backup` behind; and + * `spec-250-vendoring-identities.test.ts` reads + * `codev/research/250-criterion-8b-evidence.json` in its module body, so a + * parallel vitest worker collecting that file while this one held the mutation + * would fail on corrupted data, for reasons nothing in its own output would + * explain. + * + * The collector resolves its root from `import.meta.url`, so a COPY of the + * script under a scratch tree reads that tree's inputs — the same technique + * `rebase-drill.mjs` uses to regenerate the contract without moving the pin. No + * flag is added to the tool to suit a test, and nothing tracked is written. + * + * `agrees with the committed evidence` still runs the REAL collector against the + * REAL repository, because that is the assertion whose value depends on the + * actual committed files. Only the damage cases are relocated. + */ +function withScratchRoot(body: (root: string) => T): T { + const root = mkdtempSync(join(tmpdir(), 'spec250-evidence-')); + try { + mkdirSync(join(root, 'tools', 't3-server'), { recursive: true }); + copyFileSync(collector, join(root, 'tools', 't3-server', 'collect-spec-250-evidence.mjs')); + for (const relative of COLLECTOR_INPUTS) { + const destination = join(root, relative); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(repoRoot, relative), destination); + } + return body(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +/** Read one of the collector's inputs out of a scratch root. */ +const scratch = (root: string, relative: string) => join(root, relative); + +describe('spec 250 phase 11: the acceptance evidence collector', () => { + it('agrees with the committed evidence', () => { + // The one case that must use the REAL tree: it is asserting that the numbers + // committed to this repository still match the runs behind them. + const result = run(repoRoot, '--check'); + expect(result.status, result.stderr).toBe(0); + }); + + /** + * The failure this exists to prevent: numbers that describe a fork nobody is + * looking at any more, in the same shape as numbers that describe this one. + */ + it('refuses evidence describing a different fork, with its own exit code', () => { + const result = withScratchRoot(root => { + const criterion8b = scratch(root, 'codev/research/250-criterion-8b-evidence.json'); + const evidence = JSON.parse(readFileSync(criterion8b, 'utf8')) as Record; + evidence.forkCommit = '0'.repeat(40); + writeFileSync(criterion8b, `${JSON.stringify(evidence, null, 2)}\n`); + return run(root, '--check'); + }); + expect(result.status).toBe(3); + expect(result.stderr).toContain('STALE_RUN'); + }); + + it('fails a drifted evidence block, and not with the unreadable code', () => { + const result = withScratchRoot(root => { + const evidenceMd = scratch(root, 'codev/resources/250-acceptance-evidence.md'); + const markdown = readFileSync(evidenceMd, 'utf8'); + writeFileSync( + evidenceMd, + markdown.replace('| customization commits carried |', '| customization commits carried x |'), + ); + return run(root, '--check'); + }); + expect(result.status).toBe(1); + // The two refusals must not be spelled the same way. + expect(result.status).not.toBe(3); + }); + + /** + * Without markers the collector would have to guess where the block goes, and + * a guess produces a SECOND, contradictory table rather than an error. + */ + /** + * TWO RUNS, ONE RANGE. + * + * The churn totals come from the drill and the verdict split from + * `classify-churn`. If those two describe different ranges the table pairs + * "5 commits touch the closure" with a conflict surface measured across a + * different span, and nothing in the rendered output would say so — every cell + * is individually correct. + * + * Exit 3, not 1: a mismatched range is "I cannot make this claim", not "the + * committed evidence has drifted". + */ + it('refuses a churn classification covering a different range', () => { + const result = withScratchRoot(root => { + const movement = scratch(root, 'codev/research/250-upstream-movement.json'); + const parsed = JSON.parse(readFileSync(movement, 'utf8')); + parsed.range = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef..origin/main'; + writeFileSync(movement, JSON.stringify(parsed, null, 2)); + return run(root, '--check'); + }); + expect(result.status).toBe(3); + expect(result.stderr).toContain('STALE_RUN'); + expect(result.stderr).toContain('Two ranges in one table'); + }); + + /** + * Criterion 9 asks what UPSTREAM did. `classify-churn` will happily run + * `--fork-drift` over the fork and emit the same JSON shape, and the fork + * answering "what changed upstream" is a tautology that reports our own work + * back to us. + */ + it('refuses a churn classification run against the fork', () => { + const result = withScratchRoot(root => { + const movement = scratch(root, 'codev/research/250-upstream-movement.json'); + const parsed = JSON.parse(readFileSync(movement, 'utf8')); + parsed.identity = 'fork'; + writeFileSync(movement, JSON.stringify(parsed, null, 2)); + return run(root, '--check'); + }); + expect(result.status).toBe(3); + expect(result.stderr).toContain('WRONG_IDENTITY'); + }); + + it('refuses to guess where the block goes', () => { + const result = withScratchRoot(root => { + const evidenceMd = scratch(root, 'codev/resources/250-acceptance-evidence.md'); + const markdown = readFileSync(evidenceMd, 'utf8'); + writeFileSync(evidenceMd, markdown.replace('', '')); + return run(root, '--check'); + }); + expect(result.status).toBe(3); + expect(result.stderr).toContain('NO_MARKERS'); + }); +}); diff --git a/packages/codev/src/__tests__/spec-250-generated-contract.test.ts b/packages/codev/src/__tests__/spec-250-generated-contract.test.ts new file mode 100644 index 000000000..f5d1c48ce --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-generated-contract.test.ts @@ -0,0 +1,522 @@ +/** + * Spec 250, Phase 5 — the vendored contract, regenerated from the fork. + * + * Phases 2-4 changed the fork. Nothing in this repository knew about those + * changes: `packages/types/src/t3/generated/` was still emitted from + * `pin.upstreamBase`, so `porch-driver` and `codev-agent` would have been sending + * fields against a contract that had never heard of them. This phase regenerates, + * and these are the assertions that say it actually happened rather than that the + * generator exited zero. + * + * The centre of the file is the verdict the churn classifier refused to give. + * `classify-churn.mjs --fork-drift` reports three commits as + * `consumed-change-undecidable` — it stops being confident inside a union and + * says so instead of guessing. Regenerating without deciding those would convert + * an explicit "I could not tell" into a silent green, which is the one thing this + * project has spent five phases refusing to do. So the union change is decided + * here, by measurement: the frame that the customization added is checked against + * a union with the new alternatives and against the same union without them. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { shapeCheck, describeMismatches } from '../../../types/src/t3/shape-check.js'; +import { t3Schemas, t3Defs, t3Methods } from '../../../types/src/t3/index.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore — a dependency-free .mjs helper shared with the build tools, not a package +import { DEFAULT_UPSTREAM_ROOT } from '../../../../tools/t3-fork/identities.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..'); +const t3Root = join(repoRoot, 'packages', 'types', 'src', 't3'); +const generated = join(t3Root, 'generated'); + +const readJson = (p: string) => JSON.parse(readFileSync(p, 'utf8')); +const pin = readJson(join(t3Root, 'pin.json')); +const document = readJson(join(generated, 'schema.json')); +const defs = document.$defs as Record>; +const schemas = document.schemas as Record>; +const sourceHash = readJson(join(generated, 'source-hash.json')); +const methodsJson = readJson(join(generated, 'methods.json')); +const typeDeclarations = readFileSync(join(generated, 'types.d.ts'), 'utf8'); + +type Node = Record; +const alternatives = (node: Node): Node[] => (node.anyOf ?? node.oneOf ?? []) as Node[]; +/** The union member whose discriminant `field` is exactly `literal`. */ +function member(node: Node, field: string, literal: string): Node { + const found = alternatives(node).find((m) => m.properties?.[field]?.enum?.[0] === literal); + expect(found, `no union member with ${field} === "${literal}"`).toBeDefined(); + return found as Node; +} + +const check = (value: unknown, schema: Node, options = {}) => + shapeCheck(value, schema as never, defs as never, options); +const expectMatches = (value: unknown, schema: Node, why: string) => { + const result = check(value, schema); + expect(result.matches, `${why}\n${describeMismatches(result)}`).toBe(true); +}; + +// ---------------------------------------------------------------- provenance + +describe('spec 250: the vendored contract came from the fork', () => { + it('pins the fork head, and it is not the upstream base', () => { + expect(pin.contractSource).toBe('fork'); + expect(pin.commit).toMatch(/^[0-9a-f]{40}$/); + expect( + pin.commit, + 'the two identities have to differ for any of this to be a test rather than a tautology', + ).not.toBe(pin.upstreamBase); + }); + + /** + * Two sections, and they must DIFFER. + * + * `files` alone says "the artifacts match the source they came from", which is + * a claim the generator can make about itself. The `upstream` section is the + * other end of the comparison: it records what upstream's bytes were at + * `upstreamBase`, so the fork's divergence is a fact on disk rather than an + * inference. Two identical sections would mean the fork carries no + * customization at all, which after four phases is a failure, not a pass. + */ + it('source-hash.json carries both the fork hashes and the upstream ones', () => { + expect(sourceHash.commit).toBe(pin.commit); + expect(sourceHash.upstream?.commit).toBe(pin.upstreamBase); + expect( + sourceHash.upstream?.available, + `no upstream measurement: ${sourceHash.upstream?.reason ?? 'no reason given'}`, + ).toBe(true); + + const differing = pin.closure.filter( + (file: string) => sourceHash.files[file] !== sourceHash.upstream.files[file], + ); + expect(differing.length, 'the fork and upstream sections are identical').toBeGreaterThan(0); + expect(sourceHash.forkDrift?.measured).toBe(true); + expect([...sourceHash.forkDrift.changedFiles].sort()).toEqual([...differing].sort()); + }); + + /** + * The attribution names a commit that does not exist where it says it does. + * + * `ATTRIBUTION.md` and the `types.d.ts` header both read `pin.repo` and + * `pin.commit`, which were the same source until this phase. They are not any + * more: `pin.commit` is a fork commit, and pointing a reader at + * `pingdotgg/t3code` for it sends them somewhere it has never been. These files + * leave the repository inside a published package, so the provenance they carry + * has to be findable. + */ + it('attributes the artifacts to the fork AND to what it branched from', () => { + const attribution = readFileSync(join(generated, 'ATTRIBUTION.md'), 'utf8'); + for (const fragment of [pin.forkRepo, pin.commit, pin.repo, pin.upstreamBase, 'MIT License']) { + expect(attribution, `ATTRIBUTION.md does not name ${fragment}`).toContain(fragment); + } + }); + + /** + * Derived from the directory, not from a list of files. + * + * The first cut of this named `ATTRIBUTION.md` and `types.d.ts`. There were + * three emitted provenance headers, the third came from a separate hand-written + * string in the generator, and correcting two left the one that ships — the + * runtime module — attributing a fork-only commit to `pingdotgg/t3code`. Review + * caught it; the enumeration is what let it through. + * + * The rule instead: **any** generated artifact naming the upstream repository + * must also name the fork and the base it branched from, whatever the file is + * called. A fourth artifact acquiring a header is covered before it exists. + */ + it('no generated artifact names upstream alone', () => { + // Every artifact, with no extension filter. An earlier draft skipped `.json`, + // which is the same enumeration shape this test exists to remove — a JSON + // artifact that starts carrying a `_comment` provenance line would be exempt + // for a reason nobody chose. Filtering on the CLAIM below is the whole test; + // nothing needs excluding in advance. Review flagged it as non-blocking. + const artifacts = readdirSync(generated); + expect(artifacts.length, 'read no artifacts, so this test would pass against anything') + .toBeGreaterThan(4); + + const claiming = artifacts.filter((file) => + readFileSync(join(generated, file), 'utf8').includes(pin.repo), + ); + expect( + claiming.length, + 'no artifact carries a provenance line at all, which is not what this is checking for', + ).toBeGreaterThan(2); + + for (const file of claiming) { + const text = readFileSync(join(generated, file), 'utf8'); + expect(text, `${file} names ${pin.repo} without naming the fork it was generated from`) + .toContain(pin.forkRepo); + expect( + text, + `${file} names ${pin.repo} beside a commit that exists only in the fork, and does not ` + + 'name the upstream base a reader could actually find there', + ).toContain(pin.upstreamBase); + } + }); + + /** + * One date per identity, for the same reason there is one commit per identity. + * They were a single field while the commits were equal; now that they differ, a + * single date is right for one identity and wrong for the other with nothing in + * the file to say which. + */ + it('carries a date for each commit, and they are not the same date', () => { + expect(pin.commitDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(pin.upstreamBaseDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(pin.commitDate).not.toBe(pin.upstreamBaseDate); + // The verify line about the UPSTREAM checkout must quote the upstream date. + const harness = readFileSync(join(repoRoot, 'tools', 't3-server', 't3-server.mjs'), 'utf8'); + expect(harness).toContain('verified upstream'); + expect(harness).not.toMatch(/verified upstream[^\n]*pin\.commitDate/); + }); + + /** + * The closure did not widen. + * + * `role` is a plain `Schema.Literals` union and `ThreadId` already lived in + * `baseSchemas.ts`, so neither new field reaches a file outside the nine. The + * generator fails if the real import graph does, which makes a widening a + * deliberate decision rather than something that happens quietly; this asserts + * the decision was not made. + */ + it('still vendors exactly the nine closure files', () => { + expect(pin.closure).toHaveLength(9); + expect(Object.keys(sourceHash.files).sort()).toEqual([...pin.closure].sort()); + expect(Object.keys(sourceHash.upstream.files).sort()).toEqual([...pin.closure].sort()); + }); +}); + +// ---------------------------------------------------------------- the four fields + +describe('spec 250: the four new fields survived generation as real types', () => { + const threadCreate = member(schemas.dispatchCommandInput, 'type', 'thread.create'); + const snapshotThread = member(schemas.subscribeThreadOutput, 'kind', 'snapshot') + .properties.snapshot.properties.thread as Node; + + it('role and parentThreadId reach the command input', () => { + expect(threadCreate.properties.role).toBeDefined(); + expect(threadCreate.properties.parentThreadId).toBeDefined(); + // Optional on the wire, deliberately: an upstream client that never heard of + // either field must keep dispatching `thread.create` unchanged. + expect(threadCreate.required).not.toContain('role'); + expect(threadCreate.required).not.toContain('parentThreadId'); + }); + + it('all four reach the thread read model', () => { + for (const field of ['role', 'parentThreadId', 'codevGate', 'gateRevision']) { + expect(snapshotThread.properties[field], `${field} missing from the snapshot thread`).toBeDefined(); + } + }); + + /** + * Present is not the same as typed. `{}` emits for a schema whose constraints + * did not survive the transform, and it accepts literally any value — the + * failure mode `generated/LOSSY.md` exists to name. A field that arrived as + * `unknown` would satisfy "is in schema.json" and check nothing. + */ + it('none of them emitted as an unconstrained node', () => { + const constrained = (node: Node): boolean => { + if (!node || typeof node !== 'object') return false; + if (Object.keys(node).length === 0) return false; + if (node.$ref) return true; + const alts = alternatives(node); + if (alts.length > 0) return alts.every(constrained); + return Boolean(node.type || node.enum || node.properties); + }; + for (const field of ['role', 'parentThreadId', 'codevGate', 'gateRevision']) { + expect(constrained(snapshotThread.properties[field]), `${field} emitted with no constraints`).toBe(true); + } + // types.d.ts is the surface a caller actually programs against. + expect(typeDeclarations).toContain('"role"?: "architect" | "builder"'); + expect(typeDeclarations).toMatch(/"codevGate"\?: \{/); + expect(typeDeclarations).toMatch(/"gateRevision"(\??): number/); + expect(typeDeclarations).not.toMatch(/"(codevGate|parentThreadId)"\??: unknown/); + }); + + it('the role enum is enforced, not emitted as a bare string', () => { + const base = { + type: 'thread.create', commandId: 'cmd-1', threadId: 'thr-1', projectId: 'prj-1', + title: 'a thread', modelSelection: { model: 'sonnet' }, runtimeMode: 'full-access', + branch: null, worktreePath: null, createdAt: '2026-08-30T00:00:00.000Z', + }; + expectMatches({ ...base, role: 'builder', parentThreadId: 'thr-0' }, threadCreate, + 'a hierarchy-carrying thread.create must round-trip'); + expectMatches({ ...base, role: null, parentThreadId: null }, threadCreate, + 'an architect thread has no parent and must still round-trip'); + expect( + check({ ...base, role: 'reviewer' }, threadCreate).matches, + 'a role outside the union must not pass — if it does the enum did not survive emission', + ).toBe(false); + }); +}); + +// ---------------------------------------------------------------- codev.gateWrite + +describe('spec 250: codev.gateWrite is vendored, not merely present in the contract', () => { + /** + * `generate.mjs` iterates `Object.entries(pin.methods)`, NOT the contract's own + * RPC map. A method that exists in the fork and is absent from `pin.methods` is + * silently ignored: no schema, no entry in `methods.json`, and `checked.ts` + * reports `unchecked` for every payload — which is not a failure anyone sees. + * So the list is the thing to assert, and it is asserted in both directions. + */ + it('the pin and the generated method map agree exactly', () => { + const pinned = Object.keys(pin.methods).filter((m) => !m.startsWith('_')).sort(); + expect(Object.keys(methodsJson).sort()).toEqual(pinned); + expect(pinned).toContain('codev.gateWrite'); + }); + + it('its schemas were emitted and are reachable through the package index', () => { + expect(t3Methods['codev.gateWrite']).toEqual({ + input: 'CodevGateWriteInput', output: 'CodevGateWriteResult', stream: false, + }); + expect(t3Schemas.CodevGateWriteInput).toBeDefined(); + expect(t3Schemas.CodevGateWriteResult).toBeDefined(); + }); + + it('a gate write and its result round-trip through shapeCheck', () => { + const set = member(schemas.CodevGateWriteInput, 'type', 'codev.gate.set'); + expectMatches( + { + type: 'codev.gate.set', commandId: 'cmd-2', threadId: 'thr-1', + gate: { + gateName: 'plan-approval', requestedAt: '2026-08-30T00:00:00.000Z', + question: 'Delete the legacy table, or keep it?', + choices: [{ label: 'Delete it', consequence: 'Migrate references first.', recommended: true }], + }, + createdAt: '2026-08-30T00:00:00.000Z', + }, + set, + 'the gate-set command shape', + ); + expectMatches( + { threadId: 'thr-1', gateRevision: 4, cleared: false }, + schemas.CodevGateWriteResult, + 'the gate-write result shape', + ); + }); +}); + +// ---------------------------------------------------------------- the verdict + +/** + * The union change the churn classifier could not decide. + * + * `classify-churn.mjs` marks a comparison `unknown` the moment a union's JSON + * differs at all, additive or not. Three fork commits land there. Read member by + * member, matched on the discriminant, the cumulative change from `upstreamBase` + * to `pin.commit` is: + * + * subscribeThread output four fields added to the snapshot thread, two to the + * `thread.created` payload, and two ALTERNATIVES added + * to the `OrchestrationEvent` union + * dispatchCommand input two optional fields added to `thread.create` + * + * Nothing removed, nothing newly required, no type narrowed, no enum member lost, + * `additionalProperties` unchanged. So the verdict is non-breaking in every + * respect but one: on an OUTPUT, a new union alternative is a shape the client + * must now handle. A client shape-checking the stream against the + * pre-regeneration contract rejects a `codev.gate-set` frame outright, because it + * matches no member of the union that client knows. + * + * That is a real break, in exactly one direction, and regenerating is the fix. + * These tests measure both halves rather than restating the paragraph. + */ +describe('spec 250: the undecidable union verdict, measured', () => { + const streamOut = schemas.subscribeThreadOutput; + const eventFrame = member(streamOut, 'kind', 'event'); + const eventUnion = eventFrame.properties.event as Node; + + const gateSetFrame = { + kind: 'event', + event: { + sequence: 12, + eventId: 'evt-1', + aggregateKind: 'thread', + aggregateId: 'thr-1', + occurredAt: '2026-08-30T00:00:00.000Z', + commandId: 'cmd-2', + causationEventId: null, + correlationId: null, + metadata: {}, + type: 'codev.gate-set', + payload: { + threadId: 'thr-1', + gate: { gateName: 'plan-approval', requestedAt: '2026-08-30T00:00:00.000Z' }, + gateRevision: 4, + updatedAt: '2026-08-30T00:00:00.000Z', + }, + }, + }; + + it('the regenerated contract accepts a gate-set frame', () => { + expectMatches(gateSetFrame, streamOut, 'the frame the customization added'); + }); + + /** + * The other half, and the one that makes the first half mean something. + * + * Rebuilding the pre-regeneration union by REMOVING the two `codev.*` + * alternatives is the whole before-state that matters here: the emitted shape + * of every upstream alternative is unchanged, so the only difference between + * the old artifact and the new one, for this frame, is their presence. If the + * frame passed against this too, the union membership would not be what decides + * acceptance and the "breaking" half of the verdict would be wrong. + */ + it('a contract without those alternatives rejects it — which is what makes the change breaking', () => { + const withoutCodevEvents = { + ...streamOut, + anyOf: alternatives(streamOut).map((frame) => + frame.properties?.kind?.enum?.[0] !== 'event' + ? frame + : { + ...frame, + properties: { + ...frame.properties, + event: { + ...(frame.properties.event as Node), + anyOf: alternatives(frame.properties.event as Node).filter( + (m) => !String(m.properties?.type?.enum?.[0] ?? '').startsWith('codev.'), + ), + }, + }, + }, + ), + }; + const removed = + alternatives(eventUnion).length - alternatives( + (withoutCodevEvents.anyOf.find((f: Node) => f.properties?.kind?.enum?.[0] === 'event') as Node) + .properties.event as Node, + ).length; + expect(removed, 'the fixture removed nothing, so it is not the pre-regeneration shape').toBe(2); + expect( + check(gateSetFrame, withoutCodevEvents).matches, + 'a client on the upstream-generated contract must reject this frame', + ).toBe(false); + }); + + it('both new alternatives are there, not just the one under test', () => { + const types = alternatives(eventUnion).map((m) => m.properties?.type?.enum?.[0]); + expect(types).toContain('codev.gate-set'); + expect(types).toContain('codev.gate-cleared'); + }); + + /** + * The non-breaking half: additive means NOTHING WAS LOST. Asserting only that + * our two alternatives arrived would pass just as happily against a + * regeneration that dropped half of upstream's event types. + * + * Read from the upstream checkout at `upstreamBase`, so the comparison is + * against upstream's own source rather than against a list copied into this + * file that would go stale at the first rebase. Skips when the checkout is + * absent — "I had nothing to compare against" is not "nothing was lost". + */ + const upstreamRoot: string = process.env.T3CODE_ROOT ?? DEFAULT_UPSTREAM_ROOT; + const upstreamOrchestration = join(upstreamRoot, pin.contractsRoot, 'orchestration.ts'); + it.skipIf(!existsSync(upstreamOrchestration))( + 'kept every event type upstream had at upstreamBase', + () => { + const atBase = execFileSync( + 'git', + ['-C', upstreamRoot, 'show', `${pin.upstreamBase}:${pin.contractsRoot}/orchestration.ts`], + { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, + ); + const start = atBase.indexOf('export const OrchestrationEvent = Schema.Union(['); + expect(start, 'could not find the event union in upstream orchestration.ts').toBeGreaterThan(-1); + const end = atBase.indexOf('\n]);', start); + const upstreamTypes = [...atBase.slice(start, end).matchAll(/type: Schema\.Literal\("([^"]+)"\)/g)] + .map((m) => m[1]); + expect( + upstreamTypes.length, + 'extracted no event types, so this test would pass against anything', + ).toBeGreaterThan(10); + + const emitted = new Set(alternatives(eventUnion).map((m) => m.properties?.type?.enum?.[0])); + for (const type of upstreamTypes) { + expect(emitted.has(type), `${type} was in upstream's event union and is not in ours`).toBe(true); + } + expect(emitted.size).toBe(upstreamTypes.length + 2); + }, + ); +}); + +// ---------------------------------------------------------------- shape-check untouched + +/** + * `shape-check.ts` states what it is: a lower bound in one direction (every + * branded id lost its constraint on the way out) and stricter in another + * (`additionalProperties: false` against a decoder that ignores excess, which is + * why excess is ignored by default). Adding four fields must not quietly turn it + * into a claim of validity it does not make. + */ +describe('spec 250: the four new fields did not relax the checker', () => { + it('has no special case for any of them', () => { + const source = readFileSync(join(t3Root, 'shape-check.ts'), 'utf8'); + for (const field of ['role', 'parentThreadId', 'codevGate', 'gateRevision', 'gateWrite']) { + // Word-bounded: the claim is that no FIELD is named, not that the letters + // never occur. A substring match would fire on an ordinary English word. + expect( + new RegExp(`\\b${field}\\b`).test(source), + `shape-check.ts names ${field}; it must stay field-agnostic`, + ).toBe(false); + } + }); + + /** + * The one change this phase DID make to the checker, and why it is a + * strengthening rather than a relaxation. + * + * Phase 4's gate payload bounds `choices` to one-to-five entries, and it is the + * first schema in the vendored closure to emit `minItems`/`maxItems`. An + * unimplemented keyword makes `shapeCheck` THROW — it refuses to report a match + * for a constraint it did not check — so vendoring `codev.gateWrite` without + * implementing them left every gate-write payload check raising + * `UnsupportedKeywordError` at the call site instead of returning a result. + * Found by running the round-trip above, not by reading the schema. + * + * So the keywords are now implemented and enforced. Nothing that previously + * passed now fails, and nothing that previously failed now passes. + */ + it('enforces the bounded-array keywords rather than throwing on them', () => { + const set = member(schemas.CodevGateWriteInput, 'type', 'codev.gate.set'); + const choice = { label: 'Delete it', consequence: 'Migrate references first.' }; + const command = (choices: unknown[]) => ({ + type: 'codev.gate.set', commandId: 'cmd-4', threadId: 'thr-1', + gate: { gateName: 'plan-approval', requestedAt: '2026-08-30T00:00:00.000Z', choices }, + createdAt: '2026-08-30T00:00:00.000Z', + }); + + expect(() => check(command([choice]), set)).not.toThrow(); + expectMatches(command([choice, choice]), set, 'two choices are within the bound'); + expect(check(command([]), set).matches, 'an empty choice list is below minItems').toBe(false); + expect( + check(command(Array.from({ length: 6 }, () => choice)), set).matches, + 'six choices are above maxItems', + ).toBe(false); + }); + + it('still fails a payload missing a required field', () => { + const set = member(schemas.CodevGateWriteInput, 'type', 'codev.gate.set'); + const withoutGate = { + type: 'codev.gate.set', commandId: 'cmd-3', threadId: 'thr-1', + createdAt: '2026-08-30T00:00:00.000Z', + }; + expect(check(withoutGate, set).matches).toBe(false); + }); + + it('still mirrors the decoder on excess by default, and still tightens on request', () => { + const result = { threadId: 'thr-1', gateRevision: 4, cleared: false, somethingNew: 1 }; + expect( + check(result, schemas.CodevGateWriteResult).matches, + 'excess is ignored by default because the server strips it rather than rejecting it', + ).toBe(true); + expect( + check(result, schemas.CodevGateWriteResult, { excess: 'error' }).matches, + 'the opt-in strict mode must still report it', + ).toBe(false); + }); +}); diff --git a/packages/codev/src/__tests__/spec-250-porch-driver-hierarchy.test.ts b/packages/codev/src/__tests__/spec-250-porch-driver-hierarchy.test.ts new file mode 100644 index 000000000..8b8d52a25 --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-porch-driver-hierarchy.test.ts @@ -0,0 +1,393 @@ +/** + * Spec 250, Phase 6 — `porch-driver` publishes the hierarchy. + * + * Phase 2 put `role` and `parentThreadId` in the fork's contract, phase 3 made + * the server refuse illegal edges, and phase 5 made this repository's vendored + * contract able to describe them. None of that produced a single thread with a + * parent: nothing was sending the fields. This is the phase where the producer + * starts, so these tests are about the PAYLOAD that leaves and the refusals that + * never become payloads at all. + * + * The refusal half matters more than it looks. Two of the fork's six hierarchy + * reasons — `builder-without-parent` and `parent-on-non-builder` — need no + * projection to decide, so a client that ships them to the server is asking a + * round trip to tell it something it already knew, and burning a `thread.create` + * plus a worktree to find out. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + DriverThread, + HIERARCHY_REFUSAL_REASONS, + HierarchyRefusedError, + localHierarchyRefusal, +} from '../../../porch-driver/src/thread.js'; +import { DispatchJournal } from '../../../porch-driver/src/commands.js'; +import { TurnTracker } from '../../../porch-driver/src/turn.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..'); +const pin = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'), 'utf8'), +); + +function scratch(label: string): string { + return mkdtempSync(join(tmpdir(), `spec250-p6-${label}-`)); +} + +function recordingDispatcher(reply: (method: string, payload: unknown) => unknown = () => ({})) { + const calls: Array<{ method: string; payload: any }> = []; + return { + calls, + async call(method: string, payload: unknown) { + calls.push({ method, payload }); + return reply(method, payload); + }, + }; +} + +function deps(dir: string) { + return { + dispatcher: recordingDispatcher(), + journal: new DispatchJournal(join(dir, 'commands.jsonl')), + tracker: new TurnTracker(), + }; +} + +const baseOptions = (dir: string) => ({ + projectId: 'prj-1', + title: 'a builder', + harnessName: 'claude', + model: 'sonnet', + worktreePath: dir, + branch: 'builder/x', +}); + +// ------------------------------------------------------------ the payload + +describe('spec 250: thread.create carries the hierarchy', () => { + it('sends role and parentThreadId for a builder', async () => { + const dir = scratch('builder'); + try { + const d = deps(dir); + await DriverThread.create(d, { + ...baseOptions(dir), + role: 'builder', + parentThreadId: 'thr-architect', + }); + const create = d.dispatcher.calls.find((c) => c.payload?.type === 'thread.create'); + expect(create, 'no thread.create was dispatched').toBeDefined(); + expect(create!.payload.role).toBe('builder'); + expect(create!.payload.parentThreadId).toBe('thr-architect'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('sends role and no parent for an architect', async () => { + const dir = scratch('architect'); + try { + const d = deps(dir); + // The shape `createArchitectThread` produces: the workspace root as the + // worktree, and an empty branch that `create` turns into null. + await DriverThread.create(d, { ...baseOptions(dir), branch: '', role: 'architect' }); + const create = d.dispatcher.calls.find((c) => c.payload?.type === 'thread.create')!; + expect(create.payload.role).toBe('architect'); + expect('parentThreadId' in create.payload).toBe(false); + expect(create.payload.branch).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * The upstream-compatibility case, and the reason the keys are omitted rather + * than nulled. + * + * A caller that names no role must produce the payload it produced before this + * spec existed — byte for byte, so an upstream t3code server that has never + * heard of these fields sees nothing new. `role: null` would be a new key on + * every create. + */ + it('sends neither key when the caller names no role', async () => { + const dir = scratch('unowned'); + try { + const d = deps(dir); + await DriverThread.create(d, baseOptions(dir)); + const create = d.dispatcher.calls.find((c) => c.payload?.type === 'thread.create')!; + expect('role' in create.payload).toBe(false); + expect('parentThreadId' in create.payload).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ------------------------------------------------------------ local refusals + +describe('spec 250: the two refusals that need no server', () => { + it('names the reason for each decidable case, and null otherwise', () => { + expect(localHierarchyRefusal({ role: 'builder' })).toBe('builder-without-parent'); + expect(localHierarchyRefusal({ role: 'builder', parentThreadId: null })).toBe('builder-without-parent'); + expect(localHierarchyRefusal({ role: 'builder', parentThreadId: 'thr-a' })).toBeNull(); + + expect(localHierarchyRefusal({ role: 'architect', parentThreadId: 'thr-a' })).toBe('parent-on-non-builder'); + // No role at all, with a parent. The fork's reason covers this case too, and + // spelling it something else here would give a caller two vocabularies. + expect(localHierarchyRefusal({ parentThreadId: 'thr-a' })).toBe('parent-on-non-builder'); + + expect(localHierarchyRefusal({ role: 'architect' })).toBeNull(); + expect(localHierarchyRefusal({})).toBeNull(); + }); + + /** + * Refused BEFORE the worktree is written, not after. + * + * A refusal that has already laid down guard files, a role file and a settings + * merge has changed a directory on the strength of a create that was never + * going to happen. The assertion is on the directory, not on the error: an + * error thrown one line later would satisfy a test that only checked it threw. + */ + it('refuses a parentless builder before touching the worktree or the wire', async () => { + const dir = scratch('refused'); + try { + const d = deps(dir); + await expect( + DriverThread.create(d, { ...baseOptions(dir), role: 'builder' }), + ).rejects.toThrow(HierarchyRefusedError); + + expect(d.dispatcher.calls, 'a refused create must dispatch nothing').toHaveLength(0); + expect( + existsSync(join(dir, '.claude')), + 'the worktree was set up for a thread that was never created', + ).toBe(false); + // The journal is written by `dispatchCommand`, which was never reached. + expect(existsSync(join(dir, 'commands.jsonl'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('carries the reason and the offending ids on the error', async () => { + const dir = scratch('reason'); + try { + const d = deps(dir); + const error = await DriverThread.create(d, { + ...baseOptions(dir), + threadId: 'thr-new', + role: 'architect', + parentThreadId: 'thr-a', + }).catch((e: unknown) => e as HierarchyRefusedError); + + expect(error).toBeInstanceOf(HierarchyRefusedError); + expect(error.reason).toBe('parent-on-non-builder'); + expect(error.threadId).toBe('thr-new'); + expect(error.parentThreadId).toBe('thr-a'); + // The same sentence shape the server produces, so a log line does not + // change meaning depending on which side refused. + expect(error.message).toContain('Codev hierarchy invalid (thread.create, parent-on-non-builder)'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ------------------------------------------------------------ the copied list + +/** + * The reason vocabulary is COPIED into `porch-driver`, and the copy is checked. + * + * It cannot be imported there: `porch-driver` has no dependency on + * `@cluesmith/codev-types` and acquiring one to read six string literals would be + * the wrong trade. So the check lives here, where the generated contract already + * is. + * + * Phase 6 made this checkable IN THIS REPOSITORY. The reasons used to be declared + * only in the fork's `apps/server/src/orchestration/Errors.ts`, so the check + * needed a fork checkout and skipped without one — a copy verified only on the + * machine that wrote it. They now travel on + * `OrchestrationDispatchCommandError.refusal`, so they are in the contract and in + * `generated/schema.json`, and this runs everywhere. + */ +describe('spec 250: the copied reason list agrees with the vendored contract', () => { + const document = JSON.parse( + readFileSync(join(repoRoot, 'packages/types/src/t3/generated/schema.json'), 'utf8'), + ); + + it('names the same six hierarchy reasons the contract declares', () => { + const vendored: string[] = document.schemas.OrchestrationDispatchRefusal.properties.reason.enum; + expect(vendored.length, 'the refusal schema was not vendored').toBeGreaterThan(6); + + // The gate reasons share the union and are SCREAMING_CASE; the hierarchy ones + // are kebab-case. Partitioning on shape rather than on a second hand-written + // list is what keeps this from being the copy it is checking. + const hierarchy = vendored.filter((reason) => reason === reason.toLowerCase()); + expect([...hierarchy].sort()).toEqual([...HIERARCHY_REFUSAL_REASONS].sort()); + }); + + /** + * The gate half is checked too, because they share one union on the wire. + * + * A gate reason arriving where a hierarchy reason was expected is a real + * possibility now — `refusal.reason` is one field — and a client that switches + * only on the six would fall through on four it never heard of. + */ + it('carries the gate reasons in the same union, so a client must handle both', () => { + const vendored: string[] = document.schemas.OrchestrationDispatchRefusal.properties.reason.enum; + const gate = vendored.filter((reason) => reason === reason.toUpperCase()); + expect(gate.length).toBeGreaterThan(0); + expect(gate.every((reason) => reason.startsWith('CODEV_GATE_'))).toBe(true); + }); + + /** + * And still against the fork's own source when the checkout is present — the + * vendored artifacts are generated from it, so agreeing with them is agreeing + * with a derivative. Skips loudly rather than passing when there is nothing to + * compare against. + */ + const forkRoot = process.env.T3CODE_FORK_ROOT ?? '/Users/chris/dev/t3code-codev'; + const contractPath = join(forkRoot, 'packages', 'contracts', 'src', 'orchestration.ts'); + + it.skipIf(!existsSync(contractPath))('agrees with the fork contract at pin.commit', () => { + const source = execFileSync( + 'git', + ['-C', forkRoot, 'show', `${pin.commit}:packages/contracts/src/orchestration.ts`], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + const start = source.indexOf('export const CodevHierarchyInvalidReason = Schema.Literals(['); + expect(start, 'could not find CodevHierarchyInvalidReason in the fork contract').toBeGreaterThan(-1); + const end = source.indexOf(']);', start); + // Whole lines that are only a quoted literal. A bare `/"([a-z-]+)"/` also + // matches the doc comments above each entry — they quote `role: "builder"` + // and `role: "architect"` — which is how the first draft of this test + // "found" eight reasons and disagreed with a list that was correct. + const forkReasons = [...source.slice(start, end).matchAll(/^\s*"([a-z-]+)",?\s*$/gm)].map((m) => m[1]); + + expect(forkReasons.length, 'extracted no reasons, so this would pass against anything') + .toBeGreaterThan(3); + expect([...forkReasons].sort()).toEqual([...HIERARCHY_REFUSAL_REASONS].sort()); + }); +}); + +// ------------------------------------------------------------ the wire + +/** + * The last hop, recorded. + * + * Phase 6's acceptance criterion is a LIVE round trip: dispatch an illegal edge + * over a socket and assert the client can still tell "no such parent" from "wrong + * parent role". A unit suite cannot do that — it needs a server built from the + * fork's source, two auth exchanges and a WebSocket. So the run happens in + * `packages/t3-client/live/spec-250-hierarchy.mjs` and this asserts what it + * recorded. + * + * The first run of that script FAILED, and that failure is the reason the fork + * carries a `refusal` field at all: every discriminant arrived inside `message`, + * as English. Phase 3 fixed the engine deleting them; the ws layer was flattening + * them one hop further out, with every test beneath it green. + * + * Reproduce: + * export T3_NODE=/absolute/path/to/node T3CODE_FORK_ROOT=/path/to/fork + * export T3_HARNESS_PORT= T3_HARNESS_DIR= + * node packages/t3-client/live/spec-250-hierarchy.mjs \ + * --out codev/research/250-hierarchy-wire-evidence.json + */ +describe('spec 250: the refusal discriminant survives the ws boundary', () => { + const evidencePath = join(repoRoot, 'codev', 'research', '250-hierarchy-wire-evidence.json'); + const evidence = JSON.parse(readFileSync(evidencePath, 'utf8')); + + it('was recorded against the fork commit this repo pins', () => { + expect( + evidence.forkCommit, + 'the wire evidence describes a different fork commit than the contract was generated from', + ).toBe(pin.commit); + }); + + it('every claim held', () => { + const failed = evidence.claims.filter((claim: { passed: boolean }) => !claim.passed); + expect(failed.map((c: { name: string }) => c.name)).toEqual([]); + expect(evidence.passed).toBe(true); + }); + + /** + * FOUR DISTINCT reasons, asserted here and not only in the script. + * + * "It refused" is satisfied by a single opaque failure. The criterion is that a + * client can distinguish them, which needs the reasons to arrive intact AND to + * differ — so the evidence is read for the reasons themselves rather than for + * the script's own verdict on them. + */ + it('carries four different reasons, each a member of the contract union', () => { + const document = JSON.parse( + readFileSync(join(repoRoot, 'packages/types/src/t3/generated/schema.json'), 'utf8'), + ); + const declared: string[] = document.schemas.OrchestrationDispatchRefusal.properties.reason.enum; + + const observed = Object.entries<{ kind: string; reason?: string; tag?: string }>(evidence.observed); + expect(observed.length).toBe(4); + for (const [name, outcome] of observed) { + expect(outcome.kind, `${name} did not come back as a refusal`).toBe('refused'); + expect(outcome.reason, `${name} came back under a different reason`).toBe(name); + expect(declared, `${outcome.reason} is not in the vendored reason union`).toContain(outcome.reason); + expect(outcome.tag).toBe('CodevHierarchyInvalidError'); + } + expect(new Set(observed.map(([, o]) => o.reason)).size).toBe(4); + }); + + /** + * Recorded evidence can outlive the code it describes. + * + * The same guard `spec-146-t3-contract.test.ts` puts on the cold-start run, for + * the same reason: nothing else stops the ws layer changing while a green JSON + * file says the discriminant still travels. + */ + /** + * Recorded evidence can outlive the code it describes. + * + * HASHES, not timestamps, and both alternatives were tried. mtime flakes on a + * fresh clone: git writes files in whatever order it likes, so the evidence can + * look older than a source it is perfectly current with. Commit time fixes that + * and breaks differently — a file written, run, and THEN committed always looks + * newer than the run it produced, which is the ordinary way this one is edited. + * + * A content hash is neither. It answers what the guard means, "is this evidence + * about the code that is here now?", and it is the mechanism + * `generated/source-hash.json` already uses for the contract. + */ + it('was recorded against the code that is here now', () => { + expect(evidence.algorithm).toBe('sha256'); + const recorded: Record = evidence.sourceHashes; + expect( + Object.keys(recorded).length, + 'the evidence records no source hashes, so this would pass against anything', + ).toBeGreaterThan(2); + + // The client's read path must be among them: the claim is that a CLIENT can + // read the discriminant, so a change to how `RpcFailureError` exposes `error` + // and `tag` has to invalidate this evidence. + expect(Object.keys(recorded)).toContain('packages/t3-client/src/envelope.ts'); + expect(Object.keys(recorded)).toContain('tools/t3-server/t3-server.mjs'); + + for (const [relative, hash] of Object.entries(recorded)) { + const actual = createHash('sha256') + .update(readFileSync(join(repoRoot, relative))) + .digest('hex'); + expect( + actual, + `${relative} changed after the wire evidence was recorded — re-run it with\n` + + ` export T3_NODE=/absolute/path/to/node T3CODE_FORK_ROOT=/path/to/fork\n` + + ` export T3_HARNESS_PORT= T3_HARNESS_DIR=\n` + + ` node packages/t3-client/live/spec-250-hierarchy.mjs --out ` + + `codev/research/250-hierarchy-wire-evidence.json\n` + + `rather than trusting a stale result.`, + ).toBe(hash); + } + }); +}); diff --git a/packages/codev/src/__tests__/spec-250-rebase-drill.test.ts b/packages/codev/src/__tests__/spec-250-rebase-drill.test.ts new file mode 100644 index 000000000..3b9659ef0 --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-rebase-drill.test.ts @@ -0,0 +1,298 @@ +/** + * Spec 250 phase 11 — the drill's own evidence, checked rather than trusted. + * + * The drill writes `codev/research/250-rebase-drill.json`. A committed evidence + * file is only worth what its checks are worth, and the failure mode here is + * specific: **an evidence file that says nothing, read as an evidence file that + * says everything is fine.** So this asserts the shape AND that the run actually + * ran, and it refuses the two ways a drill can look successful without being. + * + * It does not re-run the drill. That takes a scratch clone of a 439MB repository + * and belongs in the phase, not in every suite run. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); +const evidence = JSON.parse( + readFileSync(join(repoRoot, 'codev', 'research', '250-rebase-drill.json'), 'utf8'), +) as Record; +const pin = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'), 'utf8'), +) as Record; + +/** + * WHICH OF THE TWO SHAPES THIS EVIDENCE HAS. + * + * A completed drill produces one of two, and they are NOT the same document. + * With upstream ahead of our base there is churn, a merged tree, a watermark to + * check and a closure to hash. With upstream still AT our base the drill returns + * early: it is a pass — `NO_UPSTREAM_MOVEMENT` — and it legitimately carries + * `watermark.checked: false`, `contractClosure.checked: false`, zero churn, and + * no `preserved` block, because nothing was cloned to preserve anything from. + * + * The claude lane caught this suite hard-asserting the first shape. A correct + * zero-movement re-run would have failed three assertions and thrown on a + * fourth, which is a test failing on a right answer — the mirror of the defect + * this phase spent two iterations on. So the shape is named once, and each + * branch asserts its OWN contract rather than being skipped past. + */ +const zeroMovement = evidence.signal === 'NO_UPSTREAM_MOVEMENT'; + +describe('spec 250 phase 11: the rebase drill evidence', () => { + /** + * `could-not-run` is the outcome that must never be mistaken for a pass, so it + * is the first thing asserted rather than a branch inside a later check. + */ + it('records a run that actually happened', () => { + expect(evidence.outcome).not.toBe('could-not-run'); + expect(['ok', 'conflicts']).toContain(evidence.outcome); + if (!zeroMovement) { + expect(typeof evidence.startedAt).toBe('string'); + expect(Number.isNaN(Date.parse(evidence.startedAt))).toBe(false); + } + }); + + /** + * The two shapes are distinguished by a fact, not by a guess. `signal` and + * "target equals base" must agree; if they ever disagree, every branch below + * is keyed on the wrong one and this is where that surfaces. + */ + it('agrees with itself about which shape it is', () => { + expect(zeroMovement).toBe(evidence.target === evidence.base); + }); + + /** + * THE READ-ONLY ORDER, ASSERTED AGAINST THE EVIDENCE. + * + * The drill re-reads both checkouts after it runs. This is what makes that + * self-check load-bearing rather than decorative: an evidence file recording a + * moved checkout fails here instead of sitting in the repository looking green. + */ + it.runIf(!zeroMovement)('proves nothing real moved', () => { + expect(evidence.preserved.upstreamStillAtBase).toBe(true); + expect(evidence.preserved.upstreamClean).toBe(true); + expect(evidence.preserved.forkUnmoved).toBe(true); + expect(evidence.preserved.forkClean).toBe(true); + expect(evidence.preserved.pinCommitUnchanged).toBe(true); + }); + + /** The drill must describe THIS fork, not a stale one. */ + it('was run against the pinned fork head', () => { + expect(evidence.forkHead).toBe(pin.commit); + expect(evidence.base).toBe(pin.upstreamBase); + }); + + /** + * A drill that carried nothing would report no conflicts and mean nothing. + * `commitsCarried` is what makes "3 files conflict" a fact about our + * customization rather than about an empty range. + */ + it.runIf(!zeroMovement)('carried the customization rather than an empty range', () => { + expect(evidence.commitsCarried).toBeGreaterThan(0); + expect(evidence.target).not.toBe(evidence.base); + }); + + /** + * The whole surface is the number that matters, and a rebase stopping at the + * first conflict always understates it. Asserted as a superset so the two + * measurements cannot silently disagree. + */ + it.runIf(evidence.outcome === 'conflicts')( + 'measures the whole conflict surface, not just where the rebase stopped', + () => { + // `it.runIf` rather than an early `return`: a return inside a test body is + // recorded by vitest as a PASS with zero assertions, which is the shape + // this whole phase exists to refuse. + expect(Array.isArray(evidence.wholeSurface?.conflictedFiles)).toBe(true); + for (const file of evidence.conflictedFiles as string[]) { + expect(evidence.wholeSurface.conflictedFiles).toContain(file); + } + }, + ); + + /** + * Whether the vendored contract survives is a different size of problem from + * whether the customization conflicts somewhere, so it is its own field — and + * this asserts it was actually computed, not merely absent. + */ + it.runIf(!zeroMovement)('says whether the contract is regenerable after the rebase', () => { + expect(typeof evidence.contractClosure?.regenerationReachable).toBe('boolean'); + expect(evidence.contractClosure.files).toEqual( + (pin.closure as string[]).map((file) => `${pin.contractsRoot}/${file}`), + ); + }); + + /** + * THE DOCUMENTED VOCABULARY AND THE ASSIGNABLE ONE, HELD TOGETHER. + * + * Both review lanes found the same defect in iteration 1: the header defined + * `ok` as "rebase clean, contract regenerated, shape-check held" and listed + * `regenerate-failed` and `shape-check-failed` beside it, while the code + * assigned neither and ran neither tool. A comment claimed what nothing + * checked, on the tool whose whole subject is that distinction. + * + * A prose fix alone cannot fail. This reads the file: every outcome the header + * documents must be one the code can assign, and every outcome the code can + * assign must be documented. Re-adding an unreachable state to either side + * fails here — which is the check that was missing when it was added. + */ + it('documents exactly the outcomes it can assign, and no others', () => { + const source = readFileSync(join(repoRoot, 'tools', 't3-fork', 'rebase-drill.mjs'), 'utf8'); + const header = source.split('*/')[0]; + const documented = [...header.matchAll(/^ \* ([a-z][a-z-]*) +\S/gm)].map((m) => m[1]); + const assigned = [ + ...source.matchAll(/outcome:\s*'([a-z-]+)'/g), + ...source.matchAll(/outcome\s*=\s*'([a-z-]+)'/g), + ].map((m) => m[1]); + + // A regex that matched nothing would make the comparison trivially true. + expect(documented.length).toBeGreaterThan(0); + expect(assigned.length).toBeGreaterThan(0); + expect([...new Set(documented)].sort()).toEqual([...new Set(assigned)].sort()); + }); + + /** + * WHAT THE DRILL DID NOT DO, ASSERTED AS A STATED FACT. + * + * `generate.mjs` refuses a checkout whose HEAD is not `pin.commit`, so no + * rebased tree can be regenerated from without moving the pin — the adoption + * this drill exists not to perform. That makes "shape-check did not run" a + * permanent property of the evidence, and an absent field would read as + * nobody having considered it. If the drill ever does regenerate, this test + * fails and the evidence prose has to be rewritten with it. + */ + it.runIf(!zeroMovement)('regenerates the contract from the rebased tree, and says so', () => { + const regen = evidence.contractRegeneration; + expect(regen?.attempted).toBe(true); + expect(regen.generated).toBe(true); + // The commit generated from must be one that did not exist before the drill — + // if it were `pin.commit`, the generator ran against the FORK and the whole + // answer is the fork compared to itself. + expect(regen.source.commit).toMatch(/^[0-9a-f]{40}$/); + expect(regen.source.commit).not.toBe(pin.commit); + expect(regen.source.commit).not.toBe(evidence.base); + expect(regen.source.commit).not.toBe(evidence.target); + expect(typeof regen.shapeCheckHolds).toBe('boolean'); + expect(Array.isArray(regen.artifactsDiffering)).toBe(true); + // Every shape artifact reported as moved must also be in the full list; two + // lists that can disagree are two answers to one question. + for (const file of regen.shapesDiffering as string[]) { + expect(regen.artifactsDiffering).toContain(file); + } + expect(regen.shapeCheckHolds).toBe(regen.shapesDiffering.length === 0); + }); + + /** + * `generate.mjs`'s own dangerous case, restated on this path because it is the + * other place it can occur: the closure source moved and the emitted schema did + * not. That is NOT "no effect" — every branded id in the contract emits + * unconstrained, so a relaxed constraint lands here with a zero-byte schema + * diff. The drill must compute it rather than leave it to a reader. + */ + it.runIf(!zeroMovement)('computes the hash-moved-shapes-did-not case rather than implying it', () => { + const regen = evidence.contractRegeneration; + expect(typeof regen.hashMovedShapesDidNot).toBe('boolean'); + expect(regen.hashMovedShapesDidNot).toBe( + (regen.artifactsDiffering as string[]).includes('source-hash.json') + && (regen.shapesDiffering as string[]).length === 0, + ); + }); + + /** + * THE MEASUREMENT THAT REPLACES THE CLAIM. + * + * `regenerationReachable` only says the generator would FIND its source. This + * says whether that source still hashes to what the vendored contract came + * from, using the layer `generate.mjs` names as its load-bearing detector. + * + * The ordering inside the drill is what makes it able to fail: the hash is + * taken off the merged worktree, before `merge --abort`. Taken afterwards the + * worktree is the fork again and `moved` is `[]` on every run — verified by + * hashing the unmerged fork, which reports exactly that. So a non-empty + * `moved` here is evidence the measurement is reading the merged tree. + */ + it.runIf(!zeroMovement)('measures the closure off the merged tree rather than the fork against itself', () => { + const sourceHash = evidence.contractClosure?.sourceHash; + expect(sourceHash?.checked).toBe(true); + expect(sourceHash.comparedTo).toBe(pin.commit); + expect(Object.keys(sourceHash.files)).toEqual(pin.closure); + expect(Array.isArray(sourceHash.moved)).toBe(true); + for (const file of sourceHash.moved as string[]) { + expect(pin.closure).toContain(file); + } + // Upstream moved 5 closure-touching commits in this range, so a `moved` of + // zero would mean the hash was taken after the abort — the tautology. + if ((evidence.upstreamChurn?.closureTouching ?? 0) > 0) { + expect(sourceHash.moved.length).toBeGreaterThan(0); + } + }); + + /** + * The churn numbers were prose in the first draft of the evidence — the rot + * the collector exists to stop. Counted from the preserved clone over the same + * range the drill rebased across, so the two can never describe different + * ranges, and `null` (could not count) is not 0 (nothing to count). + */ + it.runIf(!zeroMovement)('counts upstream churn over the range it rebased across', () => { + expect(typeof evidence.upstreamChurn?.commits).toBe('number'); + expect(typeof evidence.upstreamChurn.closureTouching).toBe('number'); + expect(evidence.upstreamChurn.commits).toBeGreaterThan(0); + expect(evidence.upstreamChurn.closureTouching).toBeGreaterThan(0); + expect(evidence.upstreamChurn.closureTouching) + .toBeLessThanOrEqual(evidence.upstreamChurn.commits); + expect(evidence.upstreamChurn.range) + .toBe(`${evidence.base.slice(0, 12)}..${evidence.target.slice(0, 12)}`); + }); + + /** + * The watermark check replaces "upstream must not have reached 900", which was + * the wrong invariant: the danger is upstream's migrations being SKIPPED, not + * upstream taking our number. + * + * `checked: false` is a legitimate state and is NOT a pass — asserted here so + * an unreadable migration directory cannot masquerade as a holding invariant. + */ + it.runIf(!zeroMovement)('re-checks the watermark, and does not count "not checked" as holding', () => { + expect(evidence.watermark?.checked).toBe(true); + expect(evidence.watermark.holds).toBe(true); + expect(evidence.watermark.shadowed).toEqual([]); + // A real migration must have arrived, or the invariant had nothing to bite + // on and "holds" would be vacuous. + expect(evidence.watermark.addedByUpstream.length).toBeGreaterThan(0); + for (const id of evidence.watermark.addedByUpstream as number[]) { + expect(id).toBeGreaterThan(evidence.watermark.watermarkAtBase); + } + }); + + /** + * THE OTHER SHAPE, ASSERTED ON ITS OWN TERMS. + * + * `NO_UPSTREAM_MOVEMENT` is a pass, and its three "not checked" fields are the + * right answer rather than a gap: with upstream still at our base there are no + * new migrations to shadow and no merged tree to hash. What must never happen + * is those fields going ABSENT, because an absent field reads as an oversight + * and a `checked: false` with a reason reads as the fact it is. + * + * Skipped while the committed evidence is a moved-upstream run, and it reports + * as skipped rather than as passed. + */ + it.runIf(zeroMovement)('spells its three vacuous checks as refusals carrying reasons', () => { + expect(evidence.outcome).toBe('ok'); + expect(evidence.watermark.checked).toBe(false); + expect(typeof evidence.watermark.reason).toBe('string'); + expect(evidence.contractClosure.checked).toBe(false); + expect(typeof evidence.contractClosure.reason).toBe('string'); + expect(evidence.upstreamChurn.commits).toBe(0); + expect(evidence.upstreamChurn.closureTouching).toBe(0); + // The stated refusal is carried on this path too — it was the one most + // likely to be forgotten, being an early return. Nothing was rebased and + // nothing merged, so there is no tree to generate from, and that is spelled + // differently from "the contract does not regenerate". + expect(evidence.contractRegeneration.attempted).toBe(false); + expect(evidence.contractRegeneration.reason).toContain('no tree to generate'); + }); +}); diff --git a/packages/codev/src/__tests__/spec-250-same-origin.test.ts b/packages/codev/src/__tests__/spec-250-same-origin.test.ts new file mode 100644 index 000000000..c6eefa46c --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-same-origin.test.ts @@ -0,0 +1,74 @@ +/** + * Spec 250, phase 10 — the same-origin predicate that the security claim rests on. + * + * The Playwright spec asserts that the page never makes a cross-origin request, + * and THIS function is what decides that. The review found the first version + * silently unable to fail: it compared with `url.startsWith(origin)`, a prefix + * match, against a fixed `http://localhost:5733` — so an agent host that landed + * on ports 57330-57339 (ten ports inside macOS's ephemeral range) would have had + * a genuinely direct browser-to-agent request counted as same-origin. + * + * A rare false PASS on a security claim is worse than a common one: it makes the + * test look reliable while it is not, and ~0.06% of runs is exactly the rate at + * which nobody ever sees it fail. Hence a test for the predicate itself. + */ + +import { describe, expect, it } from 'vitest'; + +import { crossOrigin } from './e2e/spec-250-same-origin.js'; + +const ORIGIN = 'http://localhost:5733'; + +describe('spec 250 phase 10: the same-origin predicate', () => { + it('passes same-origin requests through', () => { + expect( + crossOrigin( + [`${ORIGIN}/`, `${ORIGIN}/api/codev/agent/local/api/agent/v1/session`], + ORIGIN, + ), + ).toEqual([]); + }); + + /** + * THE ONE THE PREFIX MATCH FAILED. `http://localhost:57330` starts with + * `http://localhost:5733`, so the first version filtered it as same-origin — + * and it is exactly the shape a direct browser-to-agent request would take, + * because the agent host binds an ephemeral port. + */ + it('catches a port that merely starts with the right one', () => { + for (const port of [57330, 57339, 57331]) { + expect(crossOrigin([`http://localhost:${port}/api/agent/v1/session`], ORIGIN)).toEqual([ + `http://localhost:${port}/api/agent/v1/session`, + ]); + } + }); + + it('catches an ordinary cross-origin request', () => { + expect(crossOrigin(['http://127.0.0.1:4100/api/agent/v1/session'], ORIGIN)).toEqual([ + 'http://127.0.0.1:4100/api/agent/v1/session', + ]); + // Same port, different host — the other half of what a prefix match misses. + expect(crossOrigin(['http://evil.example:5733/x'], ORIGIN)).toEqual([ + 'http://evil.example:5733/x', + ]); + }); + + /** + * Exempt as a CLASS, not by name. `new URL('data:…').origin` is the string + * `"null"`, so comparing these by origin would report a false FAILURE — and + * naming `data:` alone leaves `blob:` and `about:` to break the run later. + */ + it('ignores schemes that are not requests to another origin', () => { + expect( + crossOrigin( + ['data:image/png;base64,AAAA', 'blob:http://localhost:5733/abc', 'about:blank'], + ORIGIN, + ), + ).toEqual([]); + }); + + it('is case-insensitive about the scheme and handles https', () => { + expect(crossOrigin(['HTTP://localhost:5733/x'], ORIGIN)).toEqual([]); + expect(crossOrigin(['https://box.example.ts.net:5733/x'], 'https://box.example.ts.net:5733')).toEqual([]); + }); +}); diff --git a/packages/codev/src/__tests__/spec-250-vendoring-identities.test.ts b/packages/codev/src/__tests__/spec-250-vendoring-identities.test.ts new file mode 100644 index 000000000..749c11ee6 --- /dev/null +++ b/packages/codev/src/__tests__/spec-250-vendoring-identities.test.ts @@ -0,0 +1,1320 @@ +/** + * Spec 250, Phase 1 — the two-identity vendoring harness. + * + * Spec 146 had one checkout and one meaning. Spec 250 adds a second with a + * different meaning, and the failure that creates is not a missing feature — it + * is a tool that believes it is looking at one identity while pointing at the + * other. `acquire()` checked a commit out into the read-only upstream clone, and + * both `smoke.mjs` and `live/integration.mjs` call it, so the moment `pin.commit` + * named the fork an ordinary test run would have moved the clone off its pin. + * + * These tests exist while the fork head still EQUALS `upstreamBase`, on purpose: + * every assertion below has a known answer, so a harness bug cannot hide inside a + * real customization diff. + * + * Placed in `packages/codev` for the same reason as the spec 146 suite: this is + * where `pnpm test` actually runs one. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + CHURN_MODES, + DEFAULT_FORK_ROOT, + DEFAULT_UPSTREAM_ROOT, + MISMATCH, + OK, + UNDETERMINED, + churnRange, + classifyForkHead, + contractSource, + resolveIdentities, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — a dependency-free .mjs helper shared with the build tools, not a package +} from '../../../../tools/t3-fork/identities.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..'); +const pinPath = join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'); +const harness = join(repoRoot, 'tools', 't3-server', 't3-server.mjs'); +const churn = join(repoRoot, 'tools', 't3-codegen', 'classify-churn.mjs'); + +const readJson = (p: string) => JSON.parse(readFileSync(p, 'utf8')); +const pin = readJson(pinPath); + +/** + * The REAL checkouts, for the handful of assertions that must not be made against + * a fixture. Most of this suite builds throwaway repositories on purpose — an + * exit-code claim should not depend on a developer's working tree. But the phase 5 + * flip is a claim about what ships, and a fixture cannot carry it. + */ +const UPSTREAM_ROOT: string = process.env.T3CODE_ROOT ?? DEFAULT_UPSTREAM_ROOT; +const FORK_ROOT: string = process.env.T3CODE_FORK_ROOT ?? DEFAULT_FORK_ROOT; +const FORK_ROOT_PRESENT = existsSync(FORK_ROOT) && existsSync(UPSTREAM_ROOT); + +// ---------------------------------------------------------------- throwaway repos + +/** + * A real git repository with one commit, so exit-code assertions run against + * `git` rather than against a mock that agrees with them. + */ +function makeRepo(label: string): { dir: string; head: string } { + const dir = mkdtempSync(join(tmpdir(), `t3-${label}-`)); + const git = (...args: string[]) => + execFileSync('git', ['-C', dir, ...args], { + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'spec250', GIT_AUTHOR_EMAIL: 'spec250@example.invalid', + GIT_COMMITTER_NAME: 'spec250', GIT_COMMITTER_EMAIL: 'spec250@example.invalid', + }, + }).trim(); + git('init', '-q', '-b', 'main'); + // The content is unique per repository on purpose. Two repos built from the + // same bytes, message and (fixed) identity in the same second produce the SAME + // commit sha, and an "unrelated histories" fixture that shares a commit with + // the tree it is supposed to be unrelated to tests the opposite of its name. + writeFileSync(join(dir, 'base.txt'), `base ${label} ${dir}\n`); + git('add', 'base.txt'); + git('commit', '-qm', `base ${label}`); + return { dir, head: git('rev-parse', 'HEAD') }; +} + +function gitIn(dir: string, ...args: string[]): string { + return execFileSync('git', ['-C', dir, ...args], { + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'spec250', GIT_AUTHOR_EMAIL: 'spec250@example.invalid', + GIT_COMMITTER_NAME: 'spec250', GIT_COMMITTER_EMAIL: 'spec250@example.invalid', + }, + }).trim(); +} + +/** A pin file naming whatever shas the scenario needs. */ +function writePin(dir: string, commit: string, upstreamBase: string): string { + const path = join(dir, 'pin.json'); + writeFileSync(path, JSON.stringify({ + ...pin, commit, upstreamBase, + }, null, 2)); + return path; +} + +function runVerify( + { pinFile, upstreamRoot, forkRoot }: { pinFile: string; upstreamRoot: string; forkRoot: string }, +) { + return spawnSync(process.execPath, [harness, 'verify'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: pinFile, + T3CODE_ROOT: upstreamRoot, + T3CODE_FORK_ROOT: forkRoot, + }, + }); +} + +// ---------------------------------------------------------------- pin shape + +describe('spec 250: the pin names two identities', () => { + it('carries upstreamBase alongside commit', () => { + expect(pin.upstreamBase, 'pin.json must record the upstream base the fork branched from') + .toMatch(/^[0-9a-f]{40}$/); + expect(pin.commit).toMatch(/^[0-9a-f]{40}$/); + }); + + it('names the private fork repository and branch', () => { + expect(pin.forkRepo).toContain('pseudoseed/t3code'); + expect(pin.forkBranch).toBe('codev'); + }); + + it('still points `repo` at the public upstream, which we never push to', () => { + expect(pin.repo).toContain('pingdotgg/t3code'); + }); +}); + +describe('spec 250: identity resolution', () => { + it('resolves both roots from the environment, one variable each', () => { + const ids = resolveIdentities(pin, { + T3CODE_ROOT: '/tmp/up', T3CODE_FORK_ROOT: '/tmp/fork', + }); + expect(ids.upstream.root).toBe('/tmp/up'); + expect(ids.fork.root).toBe('/tmp/fork'); + expect(ids.upstream.rootVar).toBe('T3CODE_ROOT'); + expect(ids.fork.rootVar).toBe('T3CODE_FORK_ROOT'); + }); + + it('defaults each root to its own path rather than sharing one', () => { + const ids = resolveIdentities(pin, {}); + expect(ids.upstream.root).toBe(DEFAULT_UPSTREAM_ROOT); + expect(ids.fork.root).toBe(DEFAULT_FORK_ROOT); + expect(ids.upstream.root).not.toBe(ids.fork.root); + }); + + it('pins upstream to upstreamBase and the fork to commit', () => { + const ids = resolveIdentities({ ...pin, commit: 'f'.repeat(40), upstreamBase: 'a'.repeat(40) }, {}); + expect(ids.upstream.commit).toBe('a'.repeat(40)); + expect(ids.fork.commit).toBe('f'.repeat(40)); + expect(ids.fork.base).toBe('a'.repeat(40)); + expect(ids.diverged).toBe(true); + }); + + /** + * A pre-250 pin has no `upstreamBase`. Resolving it to two identities that name + * the same commit is deliberate: the alternative is every tool growing a + * version check, and "one checkout, one meaning" is exactly what a pin without + * an `upstreamBase` means. + */ + it('reads a pin with no upstreamBase as one commit wearing both meanings', () => { + const ids = resolveIdentities({ commit: 'b'.repeat(40), repo: 'x' }, {}); + expect(ids.upstream.commit).toBe('b'.repeat(40)); + expect(ids.fork.commit).toBe('b'.repeat(40)); + expect(ids.fork.base).toBe('b'.repeat(40)); + expect(ids.diverged).toBe(false); + }); + + it('refuses a pin with no commit at all rather than resolving to undefined', () => { + expect(() => resolveIdentities({ repo: 'x' } as never, {})).toThrow(/no `commit`/); + }); +}); + +// ---------------------------------------------------------------- churn ranges + +describe('spec 250: the two churn ranges are two questions', () => { + const ids = resolveIdentities( + { ...pin, commit: 'f'.repeat(40), upstreamBase: 'a'.repeat(40) }, + { T3CODE_ROOT: '/tmp/up', T3CODE_FORK_ROOT: '/tmp/fork' }, + ); + + it('reads upstream movement from the upstream checkout, base..origin/main', () => { + const range = churnRange('upstream-movement', ids); + expect(range.root).toBe('/tmp/up'); + expect(range.from).toBe('a'.repeat(40)); + expect(range.to).toBe('origin/main'); + }); + + /** + * To HEAD, not to `pin.commit`. Once `pin.commit` was ruled to stay at + * `upstreamBase` until regeneration, measuring to it reported zero drift for a + * fork carrying real commits — "I could not tell" spelled like "nothing + * changed", on the tool whose whole job is answering "what have we changed?". + */ + it('reads fork drift from the fork checkout, base..HEAD', () => { + const range = churnRange('fork-drift', ids); + expect(range.root).toBe('/tmp/fork'); + expect(range.from).toBe('a'.repeat(40)); + expect(range.to).toBe('HEAD'); + expect(range.to).not.toBe(ids.fork.commit); + }); + + it('measures fork drift even while pin.commit still equals upstreamBase', () => { + // The exact shape of phases 2-4: the contract pin has not moved, the + // checkout has. A range of base..base would report a diverged fork as clean. + const undiverged = resolveIdentities( + { ...pin, commit: 'a'.repeat(40), upstreamBase: 'a'.repeat(40) }, + { T3CODE_FORK_ROOT: '/tmp/fork' }, + ); + const range = churnRange('fork-drift', undiverged); + expect(range.from).toBe('a'.repeat(40)); + expect(range.to).toBe('HEAD'); + expect(`${range.from}..${range.to}`).not.toBe(`${range.from}..${range.from}`); + }); + + it('never resolves the two modes to the same range', () => { + const a = churnRange('upstream-movement', ids); + const b = churnRange('fork-drift', ids); + expect(`${a.root}:${a.from}..${a.to}`).not.toBe(`${b.root}:${b.from}..${b.to}`); + }); + + it('throws on an unknown mode instead of picking one', () => { + expect(() => churnRange('whatever', ids)).toThrow(/Unknown churn mode/); + expect(Object.keys(CHURN_MODES).sort()).toEqual(['fork-drift', 'upstream-movement']); + }); +}); + +describe('spec 250: classify-churn refuses to guess which question it was asked', () => { + it('exits 1 with no mode, naming both', () => { + const result = spawnSync(process.execPath, [churn], { encoding: 'utf8' }); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('--upstream-movement'); + expect(result.stderr).toContain('--fork-drift'); + expect(result.stdout).toBe(''); + }); + + it('exits 1 with both modes rather than silently preferring one', () => { + const result = spawnSync( + process.execPath, [churn, '--upstream-movement', '--fork-drift'], { encoding: 'utf8' }, + ); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('2 modes given'); + }); + + /** + * A missing checkout is `3`. Reporting "no drift" for a checkout nobody could + * read is the exact spelling mistake this project keeps writing tests against. + */ + it('exits 3, not 0, when the checkout it was told to read is absent', () => { + const absent = join(tmpdir(), `t3-absent-${Date.now()}`); + const result = spawnSync(process.execPath, [churn, '--fork-drift'], { + encoding: 'utf8', + env: { ...process.env, T3CODE_FORK_ROOT: absent }, + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('COULD_NOT_TELL'); + }); + + it('exits 3 when a ref in the range does not resolve', () => { + const repo = makeRepo('churn-noref'); + try { + // A real repository that simply has no `origin/main`: unreadable ref, not + // "upstream has not moved". + const result = spawnSync(process.execPath, [churn, '--upstream-movement'], { + encoding: 'utf8', + env: { ...process.env, T3CODE_ROOT: repo.dir }, + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('COULD_NOT_TELL'); + } finally { + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + /** + * The `--upstream-movement` twin of the zero below. Upstream having moved is + * the normal state on this machine, so the named zero is reached with a + * throwaway checkout whose `origin/main` sits exactly where the range starts: + * a real empty range, not a mocked one. + */ + it('reports zero upstream movement as NO_UPSTREAM_MOVEMENT, exit 0', () => { + const repo = makeRepo('churn-nomove'); + try { + gitIn(repo.dir, 'update-ref', 'refs/remotes/origin/main', repo.head); + const result = spawnSync( + process.execPath, [churn, '--upstream-movement', '--since', repo.head], { + encoding: 'utf8', + env: { ...process.env, T3CODE_ROOT: repo.dir }, + }, + ); + expect(result.status).toBe(OK); + const report = JSON.parse(result.stdout); + expect(report.mode).toBe('upstream-movement'); + expect(report.identity).toBe('upstream'); + expect(report.total).toBe(0); + expect(report.signal).toBe('NO_UPSTREAM_MOVEMENT'); + expect(result.stderr).toContain('NO_UPSTREAM_MOVEMENT'); + } finally { + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + /** + * Review finding: the ref guard ran before `--since` was applied, so an + * unresolvable `--since` slipped past it and surfaced as a raw git error — + * exit 1 doing exit 3's job. + */ + it('exits 3 when --since names a ref that does not resolve', () => { + const repo = makeRepo('churn-badsince'); + try { + gitIn(repo.dir, 'update-ref', 'refs/remotes/origin/main', repo.head); + const result = spawnSync( + process.execPath, [churn, '--upstream-movement', '--since', 'f'.repeat(40)], { + encoding: 'utf8', + env: { ...process.env, T3CODE_ROOT: repo.dir }, + }, + ); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('COULD_NOT_TELL'); + } finally { + rmSync(repo.dir, { recursive: true, force: true }); + } + }); + + /** + * Zero fork drift is a real answer and exits 0 — while the fork head equals + * `upstreamBase` it is the only correct one. It is spelled `NO_FORK_DRIFT` so + * it cannot be confused with the tool having failed to look. + */ + /* + * 30s, NOT the 5s default, and the number is about what this test does rather + * than about how slow the machine is. + * + * It spawns `classify-churn`, which re-emits the whole pinned closure once per + * closure-touching commit in the range. That range GROWS as the fork gains + * customization: it was near-empty when this test was written and is 6 commits + * now, so the cost rises every phase while the budget stayed where it was. + * It crossed 5s under a loaded full-suite run in phase 11 — 2s standalone. + * + * Raising it is the honest fix rather than calling it flaky: nothing here is + * timing-sensitive, the work is real and it is bounded by the fork's history, + * and a budget that silently becomes too small turns a passing test into an + * intermittent one without anyone changing the test. + */ + it('reports zero fork drift as a named zero, exit 0', { timeout: 30_000 }, () => { + const forkRoot = process.env.T3CODE_FORK_ROOT ?? DEFAULT_FORK_ROOT; + if (!existsSync(forkRoot)) return; // covered by the absent-checkout case above + const result = spawnSync(process.execPath, [churn, '--fork-drift'], { encoding: 'utf8' }); + expect(result.status).toBe(OK); + const report = JSON.parse(result.stdout); + expect(report.mode).toBe('fork-drift'); + expect(report.identity).toBe('fork'); + expect(report.root).toBe(forkRoot); + if (report.total === 0) { + expect(report.signal).toBe('NO_FORK_DRIFT'); + } + }); +}); + +// ---------------------------------------------------------------- verify, per identity + +describe('spec 250: verify asserts each checkout against its own pin', () => { + it('exits 0 with both checkouts clean on their pins', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-ok-')); + const upstream = makeRepo('up-ok'); + const fork = makeRepo('fork-ok'); + try { + // The fork is a clone of upstream, so its merge-base with the base IS the base. + rmSync(fork.dir, { recursive: true, force: true }); + execFileSync('git', ['clone', '-q', upstream.dir, fork.dir]); + const forkHead = gitIn(fork.dir, 'rev-parse', 'HEAD'); + + const result = runVerify({ + pinFile: writePin(scratch, forkHead, upstream.head), + upstreamRoot: upstream.dir, + forkRoot: fork.dir, + }); + expect(result.stderr).toContain('verified upstream'); + expect(result.stderr).toContain('verified fork'); + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + it('exits 1 and names the UPSTREAM identity when the upstream clone moved', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-up-')); + const upstream = makeRepo('up-moved'); + const fork = makeRepo('fork-still'); + try { + const result = runVerify({ + // The pin names a sha the upstream repo does not have. + pinFile: writePin(scratch, fork.head, 'c'.repeat(40)), + upstreamRoot: upstream.dir, + forkRoot: fork.dir, + }); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('identity: upstream'); + expect(result.stderr).not.toContain('identity: fork'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + it('exits 1 and names the FORK identity when the fork moved off the contract commit', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-fork-')); + const upstream = makeRepo('up-2'); + const forkDir = join(scratch, 'fork'); + try { + // Two real commits in the fork. The pin names the second; the checkout is + // on the first, so HEAD does not descend from the contract commit. + execFileSync('git', ['clone', '-q', upstream.dir, forkDir]); + gitIn(forkDir, 'checkout', '-q', '-b', 'codev'); + writeFileSync(join(forkDir, 'ours.txt'), 'ours\n'); + gitIn(forkDir, 'add', 'ours.txt'); + gitIn(forkDir, 'commit', '-qm', 'contract commit'); + const contract = gitIn(forkDir, 'rev-parse', 'HEAD'); + gitIn(forkDir, 'checkout', '-q', '--detach', upstream.head); + + const result = runVerify({ + pinFile: writePin(scratch, contract, upstream.head), + upstreamRoot: upstream.dir, + forkRoot: forkDir, + }); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('FORK_CHECKOUT_MISMATCH'); + expect(result.stderr).toContain('identity: fork'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + /** + * A contract commit the fork repository does not even contain. Whether HEAD + * descends from it is not a question git can answer, so it is `3`. Reporting + * `1` here would say "the fork is on the wrong commit" when what happened is + * that nobody could tell. + */ + it('exits 3 when the contract commit does not exist in the fork at all', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-nocommit-')); + const upstream = makeRepo('up-2b'); + const fork = makeRepo('fork-nocommit'); + try { + const result = runVerify({ + pinFile: writePin(scratch, 'd'.repeat(40), upstream.head), + upstreamRoot: upstream.dir, + forkRoot: fork.dir, + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('NO_FORK_ANCESTRY'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + it('exits 1 when a checkout is on its pin but dirty', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-dirty-')); + const upstream = makeRepo('up-dirty'); + const fork = makeRepo('fork-clean'); + try { + writeFileSync(join(upstream.dir, 'base.txt'), 'edited\n'); + const result = runVerify({ + pinFile: writePin(scratch, fork.head, upstream.head), + upstreamRoot: upstream.dir, + forkRoot: fork.dir, + }); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('DIRTY_UPSTREAM_CHECKOUT'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + /** + * The finding this check exists for: a fork that is clean, at its pin, and + * descended from nothing we can name. A rebase that dropped the base, a squash, + * or a branch cut from somewhere else all produce it, and without the + * merge-base assertion every one of them verifies green while every fork-drift + * range computed from it is a diff between unrelated trees. + */ + it('exits 1 when the fork no longer descends from upstreamBase', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-base-')); + const upstream = makeRepo('up-base'); + const forkDir = join(scratch, 'fork'); + try { + // Upstream: root -> base. The fork HAS the base commit (it was cloned) but + // its branch was cut from the ROOT instead, which is what a rebase that + // dropped the base leaves behind. merge-base resolves fine; it is simply + // not `upstreamBase`. + const root = upstream.head; + writeFileSync(join(upstream.dir, 'base2.txt'), 'base2\n'); + gitIn(upstream.dir, 'add', 'base2.txt'); + gitIn(upstream.dir, 'commit', '-qm', 'the base'); + const base = gitIn(upstream.dir, 'rev-parse', 'HEAD'); + + execFileSync('git', ['clone', '-q', upstream.dir, forkDir]); + gitIn(forkDir, 'checkout', '-q', '-b', 'codev', root); + writeFileSync(join(forkDir, 'ours.txt'), 'ours\n'); + gitIn(forkDir, 'add', 'ours.txt'); + gitIn(forkDir, 'commit', '-qm', 'our customization, off the base'); + const forkHead = gitIn(forkDir, 'rev-parse', 'HEAD'); + + expect(gitIn(forkDir, 'merge-base', forkHead, base)).toBe(root); + + const result = runVerify({ + pinFile: writePin(scratch, forkHead, base), + upstreamRoot: upstream.dir, + forkRoot: forkDir, + }); + expect(result.status).toBe(MISMATCH); + expect(result.stderr).toContain('FORK_BASE_MISMATCH'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + /** + * A fork with an unrelated history cannot answer the question at all, and that + * is `3`. "I could not compute a merge-base" and "the merge-base is wrong" are + * different facts; collapsing them would mean a corrupt or mis-pointed checkout + * reads as a deliberate rebase. + */ + it('exits 3, not 1, when no merge-base can be computed at all', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-nobase-')); + const upstream = makeRepo('up-nobase'); + const fork = makeRepo('fork-unrelated'); // an independent history, not a clone + try { + const result = runVerify({ + pinFile: writePin(scratch, fork.head, upstream.head), + upstreamRoot: upstream.dir, + forkRoot: fork.dir, + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('NO_FORK_MERGE_BASE'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + it('exits 3, not 1, when the fork checkout is missing entirely', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-absent-')); + const upstream = makeRepo('up-3'); + try { + const result = runVerify({ + pinFile: writePin(scratch, 'e'.repeat(40), upstream.head), + upstreamRoot: upstream.dir, + forkRoot: join(scratch, 'not-there'), + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('NO_FORK_CHECKOUT'); + expect(result.stderr).toContain('T3CODE_FORK_ROOT'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + it('exits 3 when the fork HEAD cannot be read', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-nohead-')); + const upstream = makeRepo('up-4'); + const notARepo = join(scratch, 'not-a-repo'); + try { + mkdirSync(notARepo, { recursive: true }); + const result = runVerify({ + pinFile: writePin(scratch, 'e'.repeat(40), upstream.head), + upstreamRoot: upstream.dir, + forkRoot: notARepo, + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('NO_FORK_HEAD'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + /** + * Review finding: the `git status` catch fell through to the empty string, so a + * checkout whose status could not be read reported CLEAN. The comment said + * "undetermined" and the code said "fine". + * + * Triggered for real by removing read permission on `.git/index`: `rev-parse + * HEAD` needs only the ref, so it still resolves, and `git status` then exits + * 128 with "index file open failed". The failure lands between the two checks, + * which is exactly where the swallowed catch was. + */ + it('exits 3 when `git status` itself fails, rather than reporting clean', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-nostatus-')); + const upstream = makeRepo('up-nostatus'); + const index = join(upstream.dir, '.git', 'index'); + try { + chmodSync(index, 0o000); + // Running as root, or on a filesystem that ignores the mode, leaves the + // trigger absent — asserting on it then would test nothing. + const statusStillWorks = (() => { + try { + execFileSync('git', ['-C', upstream.dir, 'status', '--porcelain'], { encoding: 'utf8', stdio: 'pipe' }); + return true; + } catch { return false; } + })(); + expect( + statusStillWorks, + 'could not make `git status` fail, so this run proves nothing about the swallowed catch', + ).toBe(false); + + const result = runVerify({ + pinFile: writePin(scratch, 'e'.repeat(40), upstream.head), + upstreamRoot: upstream.dir, + forkRoot: join(scratch, 'absent'), + }); + expect(result.status).toBe(UNDETERMINED); + expect(result.stderr).toContain('NO_UPSTREAM_STATUS'); + expect(result.stderr).not.toContain('verified upstream'); + } finally { + try { chmodSync(index, 0o644); } catch { /* already gone */ } + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + it('spells the three outcomes three different ways', () => { + // 0, 1 and 3 are asserted individually above; this pins the contract that + // they are three, not two with an alias. + expect(new Set([OK, MISMATCH, UNDETERMINED]).size).toBe(3); + expect(UNDETERMINED).not.toBe(MISMATCH); + }); +}); + +// ---------------------------------------------------------------- ahead vs wrong + +/** + * The architect's ruling: `pin.commit` means "the vendored contract was generated + * from this commit", and only regeneration moves it. Phase 5 is where + * regeneration happens, so through phases 2-4 the fork checkout is legitimately + * ahead of the pin. + * + * That state is the truth and must not be silenced. It must also not be spelled + * the same as a real error: a signal that fires for three phases straight is one + * people learn to ignore, and then it fires for a real reason and nobody looks. + */ +describe('spec 250: ahead of the contract is not the same as on the wrong commit', () => { + it('reads a pin with no contractSource as upstream-sourced', () => { + expect(contractSource({ commit: 'a'.repeat(40) })).toBe('upstream'); + expect(contractSource({ contractSource: 'fork' })).toBe('fork'); + expect(contractSource({ contractSource: 'nonsense' })).toBe('upstream'); + expect(contractSource(undefined)).toBe('upstream'); + }); + + it('carries contractSource on the fork identity', () => { + expect(resolveIdentities({ ...pin, contractSource: 'fork' }, {}).fork.contractSource).toBe('fork'); + expect(resolveIdentities({ commit: 'a'.repeat(40) }, {}).fork.contractSource).toBe('upstream'); + }); + + it('classifies the three fork-head states', () => { + const at = classifyForkHead({ head: 'a', commit: 'a', descendant: true, contractSource: 'upstream' }); + expect(at).toEqual({ state: 'at-contract', ok: true, signal: null }); + + const aheadBefore = classifyForkHead({ head: 'b', commit: 'a', descendant: true, contractSource: 'upstream' }); + expect(aheadBefore.state).toBe('ahead'); + expect(aheadBefore.ok).toBe(true); + expect(aheadBefore.signal).toBe('FORK_AHEAD_OF_CONTRACT'); + + const aheadAfter = classifyForkHead({ head: 'b', commit: 'a', descendant: true, contractSource: 'fork' }); + expect(aheadAfter.state).toBe('ahead'); + expect(aheadAfter.ok, 'once the contract is fork-sourced, ahead is an error').toBe(false); + + const wrong = classifyForkHead({ head: 'b', commit: 'a', descendant: false, contractSource: 'upstream' }); + expect(wrong.state).toBe('wrong-commit'); + expect(wrong.ok).toBe(false); + expect(wrong.signal).toBe('FORK_CHECKOUT_MISMATCH'); + }); + + it('the shipped pin says the contract is fork-sourced', () => { + // Phase 5 flipped this. Through phases 2-4 it read `upstream`, so the fork + // could carry customization commits without every `verify` reporting a + // mismatch. Regeneration has happened, so a checkout ahead of `pin.commit` + // now means the contract is stale — an error, not a tolerated state. + expect(pin.contractSource).toBe('fork'); + }); + + /** + * The flip, asserted through the SHIPPED pin rather than a synthetic one. + * + * The fixtures below prove `verify` honours `contractSource`; they build their + * own pin, so they would keep passing if the shipped pin said `upstream`. This + * one reads what actually ships, which is the thing the deliverable is about: + * after phase 5, a fork HEAD one commit past `pin.commit` must NOT come back + * tolerated. + */ + it('a checkout ahead of the shipped pin is an error, not a tolerated signal', () => { + const ahead = classifyForkHead({ + head: 'a-later-commit', commit: pin.commit, descendant: true, contractSource: pin.contractSource, + }); + expect(ahead.signal).toBe('FORK_AHEAD_OF_CONTRACT'); + expect(ahead.ok, 'the contract is fork-sourced, so being ahead of it is stale, not expected').toBe(false); + }); + + /** + * The same claim end to end, against the REAL fork checkout. + * + * `classifyForkHead` is a pure function and a test that calls it directly does + * not show that `verify` exits 1 — the process could classify correctly and + * still return 0. So this runs the harness against the real fork repository + * with a pin whose `commit` is the real fork HEAD's PARENT: a genuine ancestor, + * which makes the real HEAD genuinely ahead, with no fixture repository + * standing in for the thing under test. + * + * Skips rather than passes when the fork checkout is absent — "I had nothing to + * run against" is not "it exited 1". + */ + it.skipIf(!FORK_ROOT_PRESENT)('exits 1 against the real fork checkout when it is ahead of a fork-sourced pin', () => { + const parent = execFileSync('git', ['-C', FORK_ROOT, 'rev-parse', 'HEAD~1'], { encoding: 'utf8' }).trim(); + const scratch = mkdtempSync(join(tmpdir(), 't3-real-ahead-')); + try { + const pinFile = join(scratch, 'pin.json'); + writeFileSync(pinFile, JSON.stringify({ ...pin, commit: parent, contractSource: 'fork' }, null, 2)); + const errored = runVerify({ pinFile, upstreamRoot: UPSTREAM_ROOT, forkRoot: FORK_ROOT }); + expect(errored.stderr).toContain('FORK_AHEAD_OF_CONTRACT'); + expect(errored.status, 'a fork-sourced contract the checkout has moved past is an error').toBe(MISMATCH); + + // The same checkout, the same distance ahead, with only `contractSource` + // changed. Without this the test above would pass against a harness that + // exits 1 on every ahead-ness, which is the behaviour phases 2-4 needed not + // to have. + writeFileSync(pinFile, JSON.stringify({ ...pin, commit: parent, contractSource: 'upstream' }, null, 2)); + const tolerated = runVerify({ pinFile, upstreamRoot: UPSTREAM_ROOT, forkRoot: FORK_ROOT }); + expect(tolerated.stderr).toContain('FORK_AHEAD_OF_CONTRACT'); + expect(tolerated.status, 'only contractSource changed, so only the exit code may').toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + /** Builds a fork whose HEAD is a real descendant of the contract commit. */ + function forkAheadFixture(label: string) { + const scratch = mkdtempSync(join(tmpdir(), `t3-${label}-`)); + const upstream = makeRepo(`up-${label}`); + const forkDir = join(scratch, 'fork'); + execFileSync('git', ['clone', '-q', upstream.dir, forkDir]); + gitIn(forkDir, 'checkout', '-q', '-b', 'codev'); + writeFileSync(join(forkDir, 'ours.txt'), `ours ${label}\n`); + gitIn(forkDir, 'add', 'ours.txt'); + gitIn(forkDir, 'commit', '-qm', 'a phase 2 customization'); + return { scratch, upstream, forkDir, forkHead: gitIn(forkDir, 'rev-parse', 'HEAD') }; + } + + it('exits 0 with FORK_AHEAD_OF_CONTRACT while the contract is upstream-sourced', () => { + const f = forkAheadFixture('ahead-ok'); + try { + const pinFile = join(f.scratch, 'pin.json'); + writeFileSync(pinFile, JSON.stringify({ + ...pin, commit: f.upstream.head, upstreamBase: f.upstream.head, contractSource: 'upstream', + }, null, 2)); + const result = runVerify({ pinFile, upstreamRoot: f.upstream.dir, forkRoot: f.forkDir }); + expect(result.stderr).toContain('FORK_AHEAD_OF_CONTRACT'); + expect(result.stderr).not.toContain('FORK_CHECKOUT_MISMATCH'); + expect(result.status).toBe(OK); + } finally { + rmSync(f.scratch, { recursive: true, force: true }); + rmSync(f.upstream.dir, { recursive: true, force: true }); + } + }); + + it('exits 1 with FORK_AHEAD_OF_CONTRACT once the contract is fork-sourced', () => { + const f = forkAheadFixture('ahead-err'); + try { + const pinFile = join(f.scratch, 'pin.json'); + writeFileSync(pinFile, JSON.stringify({ + ...pin, commit: f.upstream.head, upstreamBase: f.upstream.head, contractSource: 'fork', + }, null, 2)); + const result = runVerify({ pinFile, upstreamRoot: f.upstream.dir, forkRoot: f.forkDir }); + expect(result.stderr).toContain('FORK_AHEAD_OF_CONTRACT'); + expect(result.status).toBe(MISMATCH); + } finally { + rmSync(f.scratch, { recursive: true, force: true }); + rmSync(f.upstream.dir, { recursive: true, force: true }); + } + }); + + it('still exits 1 with FORK_CHECKOUT_MISMATCH for a head that does not descend', () => { + const f = forkAheadFixture('not-descendant'); + try { + const pinFile = join(f.scratch, 'pin.json'); + // The contract commit is the fork head itself; the checkout is moved BACK + // to the base, so it does not descend from the pin. + gitIn(f.forkDir, 'checkout', '-q', '--detach', f.upstream.head); + writeFileSync(pinFile, JSON.stringify({ + ...pin, commit: f.forkHead, upstreamBase: f.upstream.head, contractSource: 'upstream', + }, null, 2)); + const result = runVerify({ pinFile, upstreamRoot: f.upstream.dir, forkRoot: f.forkDir }); + expect(result.stderr).toContain('FORK_CHECKOUT_MISMATCH'); + expect(result.stderr).not.toContain('FORK_AHEAD_OF_CONTRACT'); + expect(result.status).toBe(MISMATCH); + } finally { + rmSync(f.scratch, { recursive: true, force: true }); + rmSync(f.upstream.dir, { recursive: true, force: true }); + } + }); + + it('status reports the fork state and which way the contract is sourced', () => { + const f = forkAheadFixture('status-ahead'); + try { + const pinFile = join(f.scratch, 'pin.json'); + writeFileSync(pinFile, JSON.stringify({ + ...pin, commit: f.upstream.head, upstreamBase: f.upstream.head, contractSource: 'upstream', + }, null, 2)); + const result = spawnSync(process.execPath, [harness, 'status'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: pinFile, T3CODE_ROOT: f.upstream.dir, T3CODE_FORK_ROOT: f.forkDir, + }, + }); + const report = JSON.parse(result.stdout); + expect(report.fork.state).toBe('ahead'); + expect(report.fork.ok).toBe(true); + expect(report.fork.signal).toBe('FORK_AHEAD_OF_CONTRACT'); + expect(report.fork.contractSource).toBe('upstream'); + expect(report.fork.matchesPin).toBe(false); + } finally { + rmSync(f.scratch, { recursive: true, force: true }); + rmSync(f.upstream.dir, { recursive: true, force: true }); + } + }); +}); + +// ---------------------------------------------------------------- the destructive one + +describe('spec 250: nothing writes a fork sha into the upstream clone', () => { + /** + * `acquire()` is the only verb in the harness that writes, it runs against the + * upstream clone, and `smoke.mjs` and `live/integration.mjs` both call it. If it + * checked out `pin.commit` it would move the read-only clone onto a fork sha + * from an ordinary test run — no deliberate invocation required. + * + * Measured by running it against a throwaway upstream whose HEAD is deliberately + * off `upstreamBase`, with a pin whose `commit` is a DIFFERENT sha, and asserting + * which of the two it moved to. + */ + it('acquire checks out upstreamBase, never the fork head', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-acquire-')); + const upstream = makeRepo('up-acquire'); + try { + const base = upstream.head; + writeFileSync(join(upstream.dir, 'later.txt'), 'later\n'); + gitIn(upstream.dir, 'add', 'later.txt'); + gitIn(upstream.dir, 'commit', '-qm', 'later'); + const later = gitIn(upstream.dir, 'rev-parse', 'HEAD'); + expect(later).not.toBe(base); + + // `commit` is the LATER sha, standing in for a diverged fork head. + // `upstreamBase` is the earlier one. A correct acquire lands on the base. + const result = spawnSync(process.execPath, [harness, 'acquire'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: writePin(scratch, later, base), + T3CODE_ROOT: upstream.dir, + T3CODE_FORK_ROOT: upstream.dir, + }, + }); + + expect(gitIn(upstream.dir, 'rev-parse', 'HEAD')).toBe(base); + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + it('status reports the upstream checkout against upstreamBase and the fork separately', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-status-')); + const upstream = makeRepo('up-status'); + const fork = makeRepo('fork-status'); + try { + const result = spawnSync(process.execPath, [harness, 'status'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: writePin(scratch, fork.head, upstream.head), + T3CODE_ROOT: upstream.dir, + T3CODE_FORK_ROOT: fork.dir, + }, + }); + const report = JSON.parse(result.stdout); + expect(report.pin).toBe(upstream.head); + expect(report.checkout).toBe(upstream.head); + expect(report.matchesPin).toBe(true); + expect(report.fork.head).toBe(fork.head); + expect(report.fork.matchesPin).toBe(true); + expect(report.forkPin).toBe(fork.head); + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + rmSync(fork.dir, { recursive: true, force: true }); + } + }); + + it('status reports an absent fork as unavailable, not as a mismatch', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-status-nofork-')); + const upstream = makeRepo('up-status-2'); + try { + const result = spawnSync(process.execPath, [harness, 'status'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: writePin(scratch, 'e'.repeat(40), upstream.head), + T3CODE_ROOT: upstream.dir, + T3CODE_FORK_ROOT: join(scratch, 'gone'), + }, + }); + const report = JSON.parse(result.stdout); + expect(report.fork.available).toBe(false); + expect(report.fork.matchesPin).toBe('unknown'); + // The upstream half is still a real answer, so the exit code is upstream's. + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); +}); + +// ---------------------------------------------------------------- the root readers + +describe('spec 250: every T3CODE_ROOT reader is assigned to an identity', () => { + /** + * The plan's table, asserted rather than described. A reader that resolves its + * root by re-deriving `process.env.T3CODE_ROOT ?? ''` has an + * identity by accident; one that goes through `identities.mjs` has one on + * purpose. This test is the grep the plan asked for, run every time. + */ + const readers = [ + { file: 'tools/t3-server/t3-server.mjs', identity: 'both' }, + { file: 'tools/t3-codegen/generate.mjs', identity: 'fork' }, + { file: 'tools/t3-codegen/classify-churn.mjs', identity: 'both' }, + { file: 'tools/t3-codegen/transform-blindness-probe.mjs', identity: 'fork' }, + { file: 'tools/t3-server/smoke.mjs', identity: 'upstream' }, + ]; + + it.each(readers)('$file resolves its root through identities.mjs', ({ file }) => { + const src = readFileSync(join(repoRoot, file), 'utf8'); + expect(src, `${file} must import the shared identity resolver`) + .toMatch(/from '\.\.\/t3-fork\/identities\.mjs'/); + }); + + it.each(readers)('$file no longer hardcodes a bare T3CODE_ROOT fallback path', ({ file }) => { + const src = readFileSync(join(repoRoot, file), 'utf8'); + expect(src, `${file} still re-derives its root from a literal path`) + .not.toMatch(/process\.env\.T3CODE_ROOT\s*\?\?\s*'/); + }); + + /** + * `live/integration.mjs` is the sixth reader and it deliberately does NOT use a + * default: #214 made the variable required so a missing input reads as a + * sentence rather than as a failure inside the server. Spec 250 keeps that, + * because a required variable also means the fork's path cannot arrive here by + * accident. + */ + it('live/integration.mjs keeps T3CODE_ROOT required and never falls back to the fork', () => { + const src = readFileSync(join(repoRoot, 'packages', 't3-client', 'live', 'integration.mjs'), 'utf8'); + expect(src).toContain('const T3CODE_ROOT = process.env.T3CODE_ROOT;'); + // It may NAME the fork variable in the comment explaining why it is upstream; + // it must never READ it. + expect(src).not.toMatch(/process\.env\.T3CODE_FORK_ROOT/); + }); + + it('the spec 146 contract suite gates the two live suites on two different roots', () => { + const src = readFileSync(join(here, 'spec-146-t3-contract.test.ts'), 'utf8'); + expect(src).toContain("process.env.T3CODE_FORK_ROOT ?? ''"); + expect(src).toContain('HAS_FORK_CHECKOUT'); + expect(src).toContain('HAS_CHECKOUT'); + }); + + /** + * The fork hash suite compares generated artifacts against the fork checkout, + * which is only a valid comparison while the checkout sits ON `pin.commit`. + * Through phases 2-4 it does not, so the suite skips — and a skip that nobody + * can see the end of is how a suite quietly stops existing. + * + * This pins two things: the gate is the contract commit and not merely the + * checkout's presence, and the skip carries a reason naming which case it is. + */ + it('gates the fork hash suite on the contract commit, and says why it skipped', () => { + const src = readFileSync(join(here, 'spec-146-t3-contract.test.ts'), 'utf8'); + expect(src, 'presence is not the same question as being on the contract commit') + .toContain('FORK_AT_CONTRACT'); + expect(src).toMatch(/describe\.skipIf\(!FORK_AT_CONTRACT\)/); + expect(src).toContain('forkSkipReason'); + // Three distinguishable reasons, not one blank skip. + expect(src).toContain('no fork checkout at'); + expect(src).toContain('could not read HEAD of'); + expect(src).toContain('ahead of contract commit'); + }); + + /** + * The other end of the same rule: once phase 5 sets `contractSource` to + * `"fork"`, `pin.commit` IS the fork head, so this gate opens again on its own. + * If it ever stops doing that, the suite is dead and this test says so. + */ + it('the gate reopens by itself once pin.commit names the fork head', () => { + const atContract = classifyForkHead({ + head: pin.commit, commit: pin.commit, descendant: true, contractSource: 'fork', + }); + expect(atContract.state).toBe('at-contract'); + expect(atContract.ok).toBe(true); + }); +}); + +// ---------------------------------------------------------------- source-hash + +describe('spec 250: source-hash records both ends of the comparison', () => { + const hashes = readJson(join(repoRoot, 'packages', 'types', 'src', 't3', 'generated', 'source-hash.json')); + + it('keeps the fork hashes under `files`, at pin.commit', () => { + expect(hashes.commit).toBe(pin.commit); + expect(Object.keys(hashes.files).sort()).toEqual([...pin.closure].sort()); + }); + + it('records the upstream closure at upstreamBase as its own section', () => { + expect(hashes.upstream, 'generation must record what upstream looked like at the base').toBeDefined(); + expect(hashes.upstream.commit).toBe(pin.upstreamBase); + }); + + it('spells an unmeasured upstream section differently from a matching one', () => { + if (hashes.upstream.available) { + expect(Object.keys(hashes.upstream.files).sort()).toEqual([...pin.closure].sort()); + for (const [file, digest] of Object.entries(hashes.upstream.files)) { + expect(digest, `${file} upstream hash`).toMatch(/^[0-9a-f]{64}$/); + } + } else { + // Not a pass wearing a failure's clothes: an absent measurement carries a + // reason and no file hashes at all. + expect(hashes.upstream.files).toEqual({}); + expect(hashes.upstream.reason, 'an unavailable measurement must say why').toBeTruthy(); + } + }); + + it('reports fork drift as a measured subtraction, not an assumption', () => { + expect(hashes.forkDrift).toBeDefined(); + if (hashes.upstream.available) { + expect(hashes.forkDrift.measured).toBe(true); + expect(Array.isArray(hashes.forkDrift.changedFiles)).toBe(true); + // While the fork head equals upstreamBase the answer is known: zero. + if (pin.commit === pin.upstreamBase) { + expect(hashes.forkDrift.changedFiles).toEqual([]); + } + } else { + expect(hashes.forkDrift.measured).toBe(false); + expect(hashes.forkDrift.reason).toBeTruthy(); + } + }); +}); + +// ---------------------------------------------------------------- per-identity verbs + +describe('spec 250: an upstream-only caller does not depend on the fork', () => { + /** + * Review finding: `start` was upstream-only but `ready` re-imposed the fork + * requirement one call later, so the exemption bought nothing. `smoke.mjs` and + * `live/integration.mjs` ran the both-identity `verify` for the same reason. + * + * A fork move is not `CHECKOUT_MOVED_DURING_RUN`, and it says nothing about the + * upstream process answering on the port. + */ + it('verify-upstream passes with no fork checkout at all', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-up-only-')); + const upstream = makeRepo('up-only'); + try { + const result = spawnSync(process.execPath, [harness, 'verify-upstream'], { + encoding: 'utf8', + env: { + ...process.env, + T3_PIN_FILE: writePin(scratch, 'e'.repeat(40), upstream.head), + T3CODE_ROOT: upstream.dir, + T3CODE_FORK_ROOT: join(scratch, 'absent'), + }, + }); + expect(result.stderr).toContain('verified upstream'); + expect(result.stderr).not.toContain('verified fork'); + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + it('verify-fork checks only the fork', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-fork-only-')); + const upstream = makeRepo('up-ignored'); + const forkDir = join(scratch, 'fork'); + try { + execFileSync('git', ['clone', '-q', upstream.dir, forkDir]); + const forkHead = gitIn(forkDir, 'rev-parse', 'HEAD'); + const result = spawnSync(process.execPath, [harness, 'verify-fork'], { + encoding: 'utf8', + env: { + ...process.env, + // The upstream root points at nothing; verify-fork must not care. + T3_PIN_FILE: writePin(scratch, forkHead, upstream.head), + T3CODE_ROOT: join(scratch, 'no-upstream-here'), + T3CODE_FORK_ROOT: forkDir, + }, + }); + expect(result.stderr).toContain('verified fork'); + expect(result.stderr).not.toContain('verified upstream'); + expect(result.status).toBe(OK); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); + + it('ready, smoke and the live integration script all use the upstream-only verb', () => { + const server = readFileSync(join(repoRoot, 'tools', 't3-server', 't3-server.mjs'), 'utf8'); + expect(server).toMatch(/verifyUpstream\('CHECKOUT_MOVED_DURING_RUN'\)/); + + const smoke = readFileSync(join(repoRoot, 'tools', 't3-server', 'smoke.mjs'), 'utf8'); + expect(smoke).toContain("harness('verify-upstream')"); + expect(smoke).not.toMatch(/harness\('verify'\)/); + + const live = readFileSync(join(repoRoot, 'packages', 't3-client', 'live', 'integration.mjs'), 'utf8'); + expect(live).toContain("run('verify-upstream')"); + expect(live).not.toMatch(/run\('verify'\)/); + }); + + it('bare verify still asserts both, which is the phase acceptance criterion', () => { + const scratch = mkdtempSync(join(tmpdir(), 't3-verify-both-')); + const upstream = makeRepo('up-both'); + try { + const result = runVerify({ + pinFile: writePin(scratch, 'e'.repeat(40), upstream.head), + upstreamRoot: upstream.dir, + forkRoot: join(scratch, 'absent'), + }); + // Upstream passes; the missing fork is what stops it, and it stops it at 3. + expect(result.stderr).toContain('verified upstream'); + expect(result.status).toBe(UNDETERMINED); + } finally { + rmSync(scratch, { recursive: true, force: true }); + rmSync(upstream.dir, { recursive: true, force: true }); + } + }); +}); + +// ---------------------------------------------------------------- criterion 8b + +/** + * Spec 250 criterion 8b, exercised rather than argued. + * + * Two review lanes refused an in-process simulation on an in-memory database as + * evidence for "the server is killed partway through applying the columns and the + * resulting database still opens against the PRE-FORK server binary" — correctly: + * no kill, no file, no pre-fork binary is not that criterion, it is a different + * and easier one wearing its name. + * + * `tools/t3-fork/criterion-8b.mjs` runs the real sequence against the pinned + * t3@0.0.36 server and records what happened. The assertions here are on that + * recording, and they refuse evidence older than the code it describes — the same + * shape spec 146 uses for its cold-start evidence, and for the same reason. + */ +describe('spec 250: criterion 8b, the kill test', () => { + const evidencePath = join(repoRoot, 'codev', 'research', '250-criterion-8b-evidence.json'); + const evidence = readJson(evidencePath); + + it('ran against the pinned pre-fork server, not a stand-in', () => { + expect(evidence.preForkCliVersion).toBe(pin.cliVersion); + expect(evidence.upstreamBase).toBe(pin.upstreamBase); + }); + + it('names WHICH fork commit the guard under test came from', () => { + // A path is not a version: `forkRoot` alone would describe whatever that + // checkout happened to hold at the time, so the evidence could not say which + // guard passed. + expect(evidence.forkCommit, 'the evidence must record the fork commit, not just its path') + .toMatch(/^[0-9a-f]{40}$/); + }); + + /** + * The fork half of the staleness guard. + * + * The mtime check below covers this repo's scripts. It cannot see the fork, + * where the guard itself lives — and the evidence went stale exactly that way + * once already: a fork commit changed the guard, and the recorded run still + * named the commit before it. Skipped rather than failed when the fork checkout + * is absent, because "I could not look" is not "it matches". + */ + it('describes the fork commit that is actually checked out', () => { + const forkRoot = process.env.T3CODE_FORK_ROOT ?? DEFAULT_FORK_ROOT; + if (!existsSync(forkRoot)) return; + const head = spawnSync('git', ['-C', forkRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); + if (head.status !== 0) return; + + expect( + evidence.forkCommit, + 'the criterion 8b evidence names a different fork commit than the one checked out — ' + + 'regenerate it with\n' + + ' export T3_NODE=/absolute/path/to/node T3_HARNESS_PORT=\n' + + ' node tools/t3-fork/criterion-8b.mjs --out codev/research/250-criterion-8b-evidence.json\n' + + 'rather than trusting a result recorded against an older guard.', + ).toBe(head.stdout.trim()); + }); + + it('started from a database the pre-fork server itself created and migrated', () => { + expect(evidence.steps.preForkServerCreatedDatabase).toBe(true); + expect( + evidence.steps.columnsBeforeGuard, + 'if the database already had Codev columns the run proves nothing', + ).toEqual([]); + }); + + it('killed a real process and really left the schema half applied', () => { + // SIGKILL specifically: no handler, no cleanup, no chance to finish. A clean + // exit would make the half-applied state something the script chose. + expect(evidence.steps.childKilledBySignal).toBe('SIGKILL'); + expect(evidence.steps.halfApplied).toBe(true); + expect(evidence.steps.columnsAfterKill).toHaveLength(1); + }); + + it('THE CRITERION: the pre-fork binary opens the half-applied database', () => { + expect(evidence.steps.preForkServerOpensHalfApplied).toBe(true); + }); + + /** + * Expressed as properties, not counts. The count form broke when phase 4 added + * two more guard columns while the criterion it tests still held — the same + * brittleness as a test written against a list that later grows. + */ + it('the guard sees what the crash left and finishes the rest', () => { + expect(evidence.steps.guardResume.present).toEqual(evidence.steps.columnsAfterKill); + expect(evidence.steps.guardSawWhatTheCrashLeft).toBe(true); + expect(evidence.steps.guardFinishedTheJob).toBe(true); + expect(evidence.steps.guardResume.added.length).toBeGreaterThan(0); + // Nothing added twice, and nothing left out. + expect(evidence.steps.schemaComplete).toBe(true); + expect([...evidence.steps.columnsAfterResume].sort()).toEqual( + [...evidence.steps.guardResume.present, ...evidence.steps.guardResume.added].sort(), + ); + }); + + it('and the pre-fork binary still opens it once fully applied', () => { + expect(evidence.steps.preForkServerOpensFullyApplied).toBe(true); + expect(evidence.passed).toBe(true); + }); + + /** + * Recorded evidence outlives the code it describes. Nothing stops the guard or + * the driver changing while this stays green, so this refuses evidence older + * than either. + */ + it('is not older than the code it is evidence for', () => { + const evidenceAge = statSync(evidencePath).mtimeMs; + const sources = [ + join(repoRoot, 'tools', 't3-fork', 'criterion-8b.mjs'), + join(repoRoot, 'tools', 't3-fork', 'crash-apply-child.mjs'), + join(repoRoot, 'tools', 't3-server', 't3-server.mjs'), + ]; + // The fork-side script the evidence depends on lives outside this repo, so it + // cannot be stat'd here. `forkCommit` above is what pins that half. + for (const source of sources) { + expect( + evidenceAge, + `${source} changed after the criterion 8b evidence was recorded — regenerate it with\n` + + ` export T3_NODE=/absolute/path/to/node T3_HARNESS_PORT=\n` + + ` node tools/t3-fork/criterion-8b.mjs --out codev/research/250-criterion-8b-evidence.json\n` + + `rather than trusting a stale result. The redirection is part of the command.`, + ).toBeGreaterThanOrEqual(statSync(source).mtimeMs - 1000); + } + }); +}); + +// ---------------------------------------------------------------- documentation + +describe('spec 250: the two-identity procedure is written down', () => { + it('FORK.md records the remote, branch, checkout path and phase log', () => { + const doc = readFileSync(join(repoRoot, 'tools', 't3-fork', 'FORK.md'), 'utf8'); + expect(doc).toContain('pseudoseed/t3code'); + expect(doc).toContain(DEFAULT_FORK_ROOT); + expect(doc).toContain('codev'); + expect(doc).toContain(pin.upstreamBase); + // The prohibition is the reason the repository exists in the shape it does. + expect(doc).toMatch(/gh repo create pseudoseed\/t3code --private/); + expect(doc).toContain('gh repo fork'); + }); + + it('REFRESH.md documents both churn modes and both roots', () => { + const doc = readFileSync(join(repoRoot, 'tools', 't3-codegen', 'REFRESH.md'), 'utf8'); + expect(doc).toContain('--upstream-movement'); + expect(doc).toContain('--fork-drift'); + expect(doc).toContain('T3CODE_FORK_ROOT'); + expect(doc).toContain('NO_UPSTREAM_MOVEMENT'); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-250-gate-publisher.test.ts b/packages/codev/src/agent-farm/__tests__/spec-250-gate-publisher.test.ts new file mode 100644 index 000000000..94079ee46 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-250-gate-publisher.test.ts @@ -0,0 +1,731 @@ +/** + * Spec 250, Phase 6 — the porch gate published onto a thread. + * + * The publisher has three properties that are easy to state and easy to lose: + * + * 1. `status.yaml` is authoritative; the block is a projection of it. + * 2. The publisher invents no revision. + * 3. An unconfirmed write is never spelled like an applied one. + * + * Each of those is a way a human waiting on a gate stops being visible, so each + * one is tested for the FAILURE it prevents rather than for the happy path. + */ + +import { afterEach, describe, it, expect } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + GATE_WRITE_METHOD, + GatePublisher, + startGateWatch, + T3_GATE_LIMITS, + pendingGate, + projectGate, + publishGate, + sameProjection, + type GateWriter, +} from '../servers/t3-gate-publisher.js'; +import type { PorchStatusProjection } from '../servers/status-reader.js'; +import type { GateStatus } from '../../commands/porch/types.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..', '..'); + +function status(gates: Record): PorchStatusProjection { + return { + projectId: '250', + title: 'a project', + protocol: 'spir', + phase: 'implement', + currentPlanPhase: 'phase_6', + gates, + artifactRoot: '/w', + statusPath: '/w/codev/projects/250/status.yaml', + }; +} + +/** A writer that records what it was asked to send, and answers as told. */ +function recordingWriter(reply: (payload: any) => unknown = () => ({ threadId: 't', gateRevision: 1, cleared: false })) { + const calls: Array<{ method: string; payload: any }> = []; + const writer: GateWriter & { calls: typeof calls } = { + calls, + async call(method: string, payload: unknown) { + calls.push({ method, payload: payload as any }); + return reply(payload as any); + }, + }; + return writer; +} + +/** The shape `@cluesmith/t3-client` throws for a server refusal. */ +function rpcFailure(reason: string, detail: string): Error { + const error = new Error(`t3code RPC request 1 failed (Fail): ${reason}`) as Error & { + error: unknown; + }; + error.name = 'RpcFailureError'; + error.error = { _tag: 'CodevGateWriteError', reason, detail }; + return error; +} + +// ---------------------------------------------------------------- projection + +describe('spec 250: which gate a human is waiting on', () => { + it('is the pending one, and an approved gate is not a gate', () => { + expect(pendingGate(status({ 'spec-approval': { status: 'approved' } }))).toBeNull(); + expect(pendingGate(status({}))).toBeNull(); + expect(pendingGate(status({ 'plan-approval': { status: 'pending' } }))?.name).toBe('plan-approval'); + }); + + /** + * The EARLIEST pending gate, and stably. + * + * A thread carries one block. Picking by object key order would publish + * whichever gate the YAML parser happened to yield first, and re-picking on + * every cycle makes the block flicker between two gates for as long as both are + * pending — which reads to a human as the gate being answered and re-asked. + */ + it('picks the oldest pending gate, and the same one every time', () => { + const gates = { + 'pr': { status: 'pending' as const, requested_at: '2026-08-30T12:00:00.000Z' }, + 'plan-approval': { status: 'pending' as const, requested_at: '2026-08-30T09:00:00.000Z' }, + }; + expect(pendingGate(status(gates))?.name).toBe('plan-approval'); + // Reversed insertion order, same answer. + expect(pendingGate(status({ 'plan-approval': gates['plan-approval'], pr: gates.pr }))?.name) + .toBe('plan-approval'); + }); + + it('sorts a gate with no timestamp last, not first', () => { + const picked = pendingGate(status({ + untimed: { status: 'pending' }, + 'plan-approval': { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' }, + })); + expect(picked?.name, 'an absent timestamp must not outrank a gate that has been waiting').toBe('plan-approval'); + }); +}); + +describe('spec 250: the gate block projects status.yaml', () => { + it('carries the gate name and #128 structured content intact', () => { + const projection = projectGate(status({ + 'plan-approval': { + status: 'pending', + requested_at: '2026-08-30T09:00:00.000Z', + request: { + question: 'Delete the legacy table, or keep it?', + choices: [ + { label: 'Delete it', consequence: 'Migrate references first.', recommended: true }, + { label: 'Keep it', consequence: 'Document the audit dependency.' }, + ], + terminalExcerpt: 'warning: legacy references remain', + }, + }, + })); + expect(projection.kind).toBe('set'); + if (projection.kind !== 'set') return; + expect(projection.gate.gateName).toBe('plan-approval'); + expect(projection.gate.requestedAt).toBe('2026-08-30T09:00:00.000Z'); + expect(projection.gate.question).toBe('Delete the legacy table, or keep it?'); + expect(projection.gate.choices).toHaveLength(2); + expect(projection.gate.choices?.[0].recommended).toBe(true); + expect(projection.gate.terminalExcerpt).toBe('warning: legacy references remain'); + expect(projection.dropped).toEqual([]); + }); + + /** + * THE GATE NAME NEVER GOES IN THE TITLE AGAIN. + * + * Spec 146 put it there because there was nowhere else. This asserts the name + * travels as a field — the whole reason phase 4 built the block. + */ + it('puts the gate name in a field, and no title is constructed anywhere', () => { + const projection = projectGate(status({ 'plan-approval': { status: 'pending' } })); + if (projection.kind !== 'set') throw new Error('expected a set'); + expect(projection.gate.gateName).toBe('plan-approval'); + const source = readFileSync(join(repoRoot, 'packages/codev/src/agent-farm/servers/t3-gate-publisher.ts'), 'utf8'); + expect(source, 'the publisher must not build a thread title').not.toMatch(/thread\.rename|title:/); + }); + + it('clears when nothing is pending', () => { + expect(projectGate(status({ 'spec-approval': { status: 'approved' } })).kind).toBe('clear'); + }); + + it('supplies a requestedAt when status.yaml has none, because the field is required', () => { + const projection = projectGate(status({ g: { status: 'pending' } }), () => '2026-08-30T10:00:00.000Z'); + if (projection.kind !== 'set') throw new Error('expected a set'); + expect(projection.gate.requestedAt).toBe('2026-08-30T10:00:00.000Z'); + }); +}); + +// ---------------------------------------------------------------- narrowing + +/** + * Codev bounds a gate request in BYTES; the fork bounds `CodevGate` in string + * length, and tighter. So content porch accepted can exceed what the fork will + * take — and the fork refuses an oversize gate WHOLE. + * + * The rule these tests pin: the optional content is what gets dropped, never the + * gate. `gateName` and `requestedAt` are what say a human is needed. + */ +describe('spec 250: losing the question is better than losing the gate', () => { + it('still publishes the gate when the question is over the fork limit', () => { + const projection = projectGate(status({ + 'plan-approval': { + status: 'pending', + requested_at: '2026-08-30T09:00:00.000Z', + request: { question: 'q'.repeat(T3_GATE_LIMITS.question + 1), choices: [] }, + }, + })); + if (projection.kind !== 'set') throw new Error('the gate was lost with the question'); + expect(projection.gate.gateName).toBe('plan-approval'); + expect(projection.gate.question).toBeUndefined(); + expect(projection.dropped.join(' ')).toContain('question'); + }); + + it('flattens a multi-line question rather than letting the fork refuse the gate', () => { + const projection = projectGate(status({ + g: { status: 'pending', request: { question: 'first\nsecond', choices: [] } }, + })); + if (projection.kind !== 'set') throw new Error('expected a set'); + expect(projection.gate.question).toBe('first second'); + }); + + it('keeps at most five choices and at most one recommendation', () => { + const choice = (n: number, recommended?: boolean) => ({ + label: `choice ${n}`, + consequence: 'something happens', + ...(recommended === undefined ? {} : { recommended }), + }); + const projection = projectGate(status({ + g: { + status: 'pending', + request: { + question: 'which?', + choices: [choice(1, true), choice(2, true), choice(3), choice(4), choice(5), choice(6)], + }, + }, + })); + if (projection.kind !== 'set') throw new Error('expected a set'); + expect(projection.gate.choices).toHaveLength(T3_GATE_LIMITS.maxChoices); + expect(projection.gate.choices!.filter((c) => c.recommended === true)).toHaveLength(1); + expect(projection.gate.choices![0].recommended).toBe(true); + // Both narrowings are reported. A silently demoted recommendation is a + // changed answer, and a silently dropped choice is one a human never sees. + expect(projection.dropped.join(' ')).toContain('recommended'); + expect(projection.dropped.join(' ')).toContain('past the 5-choice limit'); + }); + + it('truncates a long terminal excerpt from the head, and says it did', () => { + const excerpt = 'x'.repeat(T3_GATE_LIMITS.terminalExcerpt + 500) + 'THE INTERESTING TAIL'; + const projection = projectGate(status({ g: { status: 'pending', request: { question: 'q', choices: [], terminalExcerpt: excerpt } } })); + if (projection.kind !== 'set') throw new Error('expected a set'); + const kept = projection.gate.terminalExcerpt!; + expect(kept.length).toBeLessThanOrEqual(T3_GATE_LIMITS.terminalExcerpt); + expect(kept, 'the end of an excerpt is the part that says what happened').toContain('THE INTERESTING TAIL'); + expect(kept, 'a fragment must not read as the whole output').toContain('truncated'); + expect(projection.dropped.join(' ')).toContain('terminalExcerpt'); + }); + + /** + * The one case where the gate IS dropped, and why it is the right call. + * + * A `gateName` cannot be shortened without changing which gate it names, so + * publishing a truncated one would show a human a gate they cannot match to + * their protocol. Reporting no gate is worse than reporting the right one and + * better than reporting a different one. + */ + it('reports no gate rather than a renamed one when the name is unusable', () => { + expect(projectGate(status({ ['g'.repeat(T3_GATE_LIMITS.gateName + 1)]: { status: 'pending' } })).kind).toBe('clear'); + }); +}); + +// ---------------------------------------------------------------- the write + +describe('spec 250: the publisher sends no revision', () => { + it('omits revision from both command shapes', async () => { + const writer = recordingWriter(); + await publishGate(writer, 'thr-1', projectGate(status({ g: { status: 'pending' } }))); + await publishGate(writer, 'thr-1', { kind: 'clear' }); + + expect(writer.calls.map((c) => c.method)).toEqual([GATE_WRITE_METHOD, GATE_WRITE_METHOD]); + expect(writer.calls[0].payload.type).toBe('codev.gate.set'); + expect(writer.calls[1].payload.type).toBe('codev.gate.clear'); + for (const call of writer.calls) { + expect( + 'revision' in call.payload, + 'a revision held in a writer’s memory resets on restart, and a reset counter renders ' + + 'every later gate as "no gate pending"', + ).toBe(false); + expect(typeof call.payload.commandId).toBe('string'); + } + }); + + it('reads the revision the server returned', async () => { + const writer = recordingWriter(() => ({ threadId: 'thr-1', gateRevision: 7, cleared: false })); + const outcome = await publishGate(writer, 'thr-1', projectGate(status({ g: { status: 'pending' } }))); + expect(outcome).toEqual({ kind: 'applied', gateRevision: 7, cleared: false }); + }); +}); + +describe('spec 250: unconfirmed is not applied and not refused', () => { + it('reports a transport failure as unconfirmed', async () => { + const writer: GateWriter = { async call() { throw new Error('socket closed'); } }; + const outcome = await publishGate(writer, 'thr-1', { kind: 'clear' }); + expect(outcome.kind).toBe('unconfirmed'); + }); + + /** + * A response that returned and cannot be read. + * + * This is the case that most wants to be called success — the call did not + * throw. It carries no revision, so nothing is known about what the thread now + * holds, and reporting it applied is how a gate that was never set gets + * rendered as set. + */ + it('reports an unreadable response as unconfirmed, not applied', async () => { + const writer = recordingWriter(() => ({ ok: true })); + const outcome = await publishGate(writer, 'thr-1', { kind: 'clear' }); + expect(outcome.kind).toBe('unconfirmed'); + }); + + it('reads a server refusal with its reason discriminant', async () => { + const writer: GateWriter = { + async call() { throw rpcFailure('CODEV_GATE_REVISION_STALE', 'revision 3 is at or below the mark'); }, + }; + const outcome = await publishGate(writer, 'thr-1', { kind: 'clear' }); + expect(outcome.kind).toBe('refused'); + if (outcome.kind !== 'refused') return; + expect(outcome.reason).toBe('CODEV_GATE_REVISION_STALE'); + expect(outcome.detail).toContain('at or below the mark'); + }); +}); + +// ---------------------------------------------------------------- suppression + +describe('spec 250: the publish memory suppresses writes and never decides state', () => { + it('does not resend an identical projection', async () => { + const writer = recordingWriter(); + const publisher = new GatePublisher(writer); + const pending = status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } }); + + expect((await publisher.publish('thr-1', pending))?.kind).toBe('applied'); + expect(await publisher.publish('thr-1', pending), 'nothing to send is not the same as sent').toBeNull(); + expect(writer.calls).toHaveLength(1); + }); + + /** + * The property that makes an unconfirmed write safe to have. + * + * If a failed write updated the memory, the next cycle would see "already + * published" and send nothing — so a gate that never landed would stay + * unpublished for as long as `status.yaml` did not change, which for a gate + * waiting on a human is forever. + */ + it('retries after an unconfirmed write, because nothing was confirmed', async () => { + let first = true; + const writer = recordingWriter(() => { + if (first) { first = false; throw new Error('socket closed'); } + return { threadId: 'thr-1', gateRevision: 2, cleared: false }; + }); + const publisher = new GatePublisher(writer); + const pending = status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } }); + + expect((await publisher.publish('thr-1', pending))?.kind).toBe('unconfirmed'); + expect((await publisher.publish('thr-1', pending))?.kind).toBe('applied'); + expect(writer.calls).toHaveLength(2); + }); + + it('retries after a refusal too, for the same reason', async () => { + let first = true; + const writer = recordingWriter(() => { + if (first) { first = false; throw rpcFailure('CODEV_GATE_REVISION_STALE', 'another writer got there'); } + return { threadId: 'thr-1', gateRevision: 4, cleared: false }; + }); + const publisher = new GatePublisher(writer); + const pending = status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } }); + expect((await publisher.publish('thr-1', pending))?.kind).toBe('refused'); + expect((await publisher.publish('thr-1', pending))?.kind).toBe('applied'); + }); + + it('sends the clear when the gate is approved', async () => { + const writer = recordingWriter(); + const publisher = new GatePublisher(writer); + await publisher.publish('thr-1', status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } })); + await publisher.publish('thr-1', status({ g: { status: 'approved', approved_at: '2026-08-30T10:00:00.000Z' } })); + expect(writer.calls.map((c) => c.payload.type)).toEqual(['codev.gate.set', 'codev.gate.clear']); + }); + + /** + * Reconnect republishes CURRENT state, and does not replay history. + * + * A new connection has published nothing, whatever this process remembers about + * the old one — the server it is now talking to may not be the server that + * confirmed those writes. + */ + it('republishes everything after forget(), with no history in between', async () => { + const writer = recordingWriter(); + const publisher = new GatePublisher(writer); + const pending = status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } }); + + await publisher.publish('thr-1', pending); + // Two intervening states that were never published. A replay would send them. + publisher.forget(); + await publisher.publish('thr-1', pending); + + expect(writer.calls).toHaveLength(2); + expect(writer.calls.every((c) => c.payload.type === 'codev.gate.set')).toBe(true); + expect(writer.calls[0].payload.gate).toEqual(writer.calls[1].payload.gate); + }); + + it('reports dropped content to the caller rather than swallowing it', async () => { + const dropped: Array<{ threadId: string; items: ReadonlyArray }> = []; + const publisher = new GatePublisher(recordingWriter(), (threadId, items) => + dropped.push({ threadId, items: [...items] })); + await publisher.publish('thr-1', status({ + g: { status: 'pending', request: { question: 'q'.repeat(T3_GATE_LIMITS.question + 1), choices: [] } }, + })); + expect(dropped).toHaveLength(1); + expect(dropped[0].threadId).toBe('thr-1'); + expect(dropped[0].items.join(' ')).toContain('question'); + }); +}); + +describe('spec 250: sameProjection', () => { + it('treats two clears as the same and a changed gate as different', () => { + const a = projectGate(status({ g: { status: 'pending', requested_at: '2026-08-30T09:00:00.000Z' } })); + const b = projectGate(status({ g: { status: 'pending', requested_at: '2026-08-30T11:00:00.000Z' } })); + expect(sameProjection({ kind: 'clear' }, { kind: 'clear' })).toBe(true); + expect(sameProjection(a, { kind: 'clear' })).toBe(false); + expect(sameProjection(a, a)).toBe(true); + expect(sameProjection(a, b), 'a gate re-requested at a new time is a new gate').toBe(false); + }); +}); + +// ---------------------------------------------------------------- the limits + +/** + * The fork's caps are COPIED here, so they are checked against the fork. + * + * The vendored contract cannot supply them for the string fields — the emitter + * drops checks behind `TrimmedNonEmptyString`'s transform, which is exactly what + * `generated/LOSSY.md` records. The array bounds DO survive, so those are checked + * against the artifacts; the string caps are checked against the fork source when + * the checkout is present. + */ +describe('spec 250: the copied gate limits agree with the contract', () => { + it('matches the choice bounds that survived emission', () => { + const document = JSON.parse( + readFileSync(join(repoRoot, 'packages/types/src/t3/generated/schema.json'), 'utf8'), + ); + const setCommand = document.schemas.CodevGateWriteInput.anyOf.find( + (member: any) => member.properties?.type?.enum?.[0] === 'codev.gate.set', + ); + const bounds = setCommand.properties.gate.properties.choices; + const emitted = JSON.stringify(bounds); + expect(emitted).toContain(`"minItems":${T3_GATE_LIMITS.minChoices}`); + expect(emitted).toContain(`"maxItems":${T3_GATE_LIMITS.maxChoices}`); + }); + + const forkRoot = process.env.T3CODE_FORK_ROOT ?? '/Users/chris/dev/t3code-codev'; + const contractPath = join(forkRoot, 'packages/contracts/src/orchestration.ts'); + it.skipIf(!existsSync(contractPath))('matches the string caps in the fork source', () => { + const source = readFileSync(contractPath, 'utf8'); + const block = source.slice( + source.indexOf('export const CodevGateChoice = Schema.Struct({'), + source.indexOf('export type CodevGate = typeof CodevGate.Type;'), + ); + expect(block.length, 'found no CodevGate block, so this would pass against anything') + .toBeGreaterThan(200); + for (const [field, limit] of [ + ['gateName', T3_GATE_LIMITS.gateName], + ['question', T3_GATE_LIMITS.question], + ['label', T3_GATE_LIMITS.label], + ['consequence', T3_GATE_LIMITS.consequence], + ['terminalExcerpt', T3_GATE_LIMITS.terminalExcerpt], + ] as const) { + expect(block, `${field}'s cap in the fork is not ${limit}`).toContain(`isMaxLength(${limit})`); + } + }); +}); + +// ---------------------------------------------------------------- integration + +/** + * The publish cycle over a REAL workspace on disk. + * + * Everything above drives the projection and the writer directly. That proves the + * pieces and not the path: `readWorkspaceStatuses` reads + * `codev/projects//status.yaml` under a root, rejects symlinks, and returns + * a `threadId` only when the file carries `thread_id` — and a publisher that never + * meets that reader is a publisher whose join key is a guess. + * + * So these write real `status.yaml` files and run `startGateWatch`'s own cycle + * over them, with `readStatuses` left at its default. + */ +describe('spec 250: a status.yaml walked from pending to approved', () => { + const roots: string[] = []; + function workspace(): string { + const dir = mkdtempSync(join(tmpdir(), 'spec250-gate-ws-')); + roots.push(dir); + return dir; + } + function writeStatus(root: string, name: string, body: string): void { + const dir = join(root, 'codev', 'projects', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'status.yaml'), body); + } + const statusYaml = (gates: string, threadId = 'thr-1') => + `id: "250"\ntitle: a project\nprotocol: spir\nphase: implement\ncurrent_plan_phase: phase_6\n` + + `thread_id: "${threadId}"\ngates:\n${gates}`; + + function watchOver(root: string, writer: ReturnType) { + // `readStatuses` is NOT injected: the point is to exercise the real reader. + return startGateWatch({ workspaceRoot: root, writer, debounceMs: 5, reconcileMs: 60_000 }); + } + + afterEach(() => { + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it('publishes a pending gate, clears it on approval, then publishes the next one', async () => { + const root = workspace(); + const writer = recordingWriter(); + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: pending\n requested_at: "2026-08-30T09:00:00.000Z"\n`, + )); + const watch = watchOver(root, writer); + try { + await watch.publishNow(); + expect(writer.calls.map((c) => c.payload.type)).toEqual(['codev.gate.set']); + expect(writer.calls[0].payload.threadId).toBe('thr-1'); + expect(writer.calls[0].payload.gate.gateName).toBe('plan-approval'); + + // Approved. The block clears. + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: approved\n approved_at: "2026-08-30T10:00:00.000Z"\n`, + )); + await watch.publishNow(); + expect(writer.calls.map((c) => c.payload.type)).toEqual(['codev.gate.set', 'codev.gate.clear']); + + // The next gate opens. + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: approved\n approved_at: "2026-08-30T10:00:00.000Z"\n` + + ` pr:\n status: pending\n requested_at: "2026-08-30T11:00:00.000Z"\n`, + )); + await watch.publishNow(); + expect(writer.calls.map((c) => c.payload.type)) + .toEqual(['codev.gate.set', 'codev.gate.clear', 'codev.gate.set']); + expect(writer.calls[2].payload.gate.gateName).toBe('pr'); + } finally { + watch.close(); + } + }); + + /** + * The serialization, tested for the property it was written for. + * + * The first version dropped a request while one was in flight and returned + * `[]`. The integration test above catches that indirectly — the watcher fires + * on the same write a caller reacts to — but indirectly is not the same as + * deliberately, and review was right that a documented bug fix with no direct + * test is one line from regressing silently. + * + * Two cycles started WITHOUT awaiting the first: both must run, in order, and + * neither may return `[]` for having been skipped. The writer blocks until + * released, so the second cycle provably starts while the first is in flight. + */ + it('runs every requested cycle, in order, when they overlap', async () => { + const root = workspace(); + let release: (() => void) | null = null; + let blocked = true; + let inFlight = 0; + let peak = 0; + const writer = { + calls: [] as Array<{ method: string; payload: any }>, + async call(method: string, payload: unknown) { + inFlight += 1; + peak = Math.max(peak, inFlight); + writer.calls.push({ method, payload: payload as any }); + if (blocked) { + blocked = false; + await new Promise((res) => { release = res; }); + } + inFlight -= 1; + return { threadId: 'thr-1', gateRevision: writer.calls.length, cleared: false }; + }, + }; + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: pending\n requested_at: "2026-08-30T09:00:00.000Z"\n`, + )); + // A long debounce so the WATCHER does not fire during this test. The claim + // here is about two explicit requests overlapping; a third cycle arriving from + // the file watcher would publish first and let suppression hide a dropped one. + const watch = startGateWatch({ + workspaceRoot: root, writer: writer as never, debounceMs: 60_000, reconcileMs: 60_000, + }); + try { + const first = watch.publishNow(); + await new Promise((r) => setTimeout(r, 20)); + expect(release, 'the first cycle never reached the writer').not.toBeNull(); + + // The gate CHANGES while the first cycle is still in flight, and the second + // request goes in behind it. If the second were dropped, this new gate would + // never publish — which is the failure the serialization exists to prevent. + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: approved\n approved_at: "2026-08-30T10:00:00.000Z"\n` + + ` pr:\n status: pending\n requested_at: "2026-08-30T11:00:00.000Z"\n`, + )); + const second = watch.publishNow(); + (release as unknown as () => void)(); + + await Promise.all([first, second]); + + /** + * Asserted on the WRITES, not on which promise carried which result. + * + * `watchAgentState` queues a cycle of its own when it subscribes, so the + * number of cycles is three and which one publishes what is an + * implementation detail of the queue. What the serialization has to + * guarantee is visible here and nowhere else: BOTH gates reached the + * server, in the order `status.yaml` held them. A dropped request publishes + * `plan-approval` and never `pr`, which is precisely the bug — the gate a + * human is now waiting on is the one that goes missing. + */ + expect( + writer.calls.map((call) => call.payload.gate?.gateName), + 'a cycle was dropped: the gate that opened during the in-flight write never published', + ).toEqual(['plan-approval', 'pr']); + expect(peak, 'two cycles overlapped, so they could race each other’s revisions').toBe(1); + } finally { + watch.close(); + } + }); + + it('publishes nothing for a status.yaml with no thread_id', async () => { + const root = workspace(); + const writer = recordingWriter(); + const dir = join(root, 'codev', 'projects', '250-no-thread'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'status.yaml'), + `id: "250"\ntitle: t\nprotocol: spir\nphase: implement\ncurrent_plan_phase: p\n` + + `gates:\n plan-approval:\n status: pending\n`, + ); + const watch = watchOver(root, writer); + try { + await watch.publishNow(); + expect(writer.calls, 'no join key means no thread to publish onto').toHaveLength(0); + } finally { + watch.close(); + } + }); + + /** + * An unreadable `status.yaml` publishes NOTHING — it does not clear. + * + * Clearing would spell "I could not read the file" exactly like "no gate is + * pending", on the one thread where a human may be waiting. This is the + * hot-tier rule applied to the failure mode it was written for. + */ + it('sends no clear when status.yaml cannot be parsed', async () => { + const root = workspace(); + const writer = recordingWriter(); + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: pending\n requested_at: "2026-08-30T09:00:00.000Z"\n`, + )); + const watch = watchOver(root, writer); + try { + await watch.publishNow(); + expect(writer.calls).toHaveLength(1); + + writeStatus(root, '250-a-project', 'not: [valid, yaml\n - at all'); + await watch.publishNow(); + expect( + writer.calls, + 'an unreadable status.yaml must not clear a gate a human is waiting on', + ).toHaveLength(1); + } finally { + watch.close(); + } + }); + + /** + * Spec test scenario 4: killing and restarting mid-gate leaves the rendered + * gate matching `status.yaml`. + * + * The restart is modelled by building a SECOND watch over the same workspace — + * a new process has a new `GatePublisher` and remembers nothing. What makes the + * outcome right is that the new one re-reads the file rather than replaying + * what the old one saw: the gate it publishes is whatever `status.yaml` says + * NOW, including a gate that was approved while nothing was running. + */ + it('matches status.yaml after a restart, including a change made while it was down', async () => { + const root = workspace(); + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: pending\n requested_at: "2026-08-30T09:00:00.000Z"\n`, + )); + + const before = recordingWriter(); + const first = watchOver(root, before); + await first.publishNow(); + first.close(); + expect(before.calls.map((c) => c.payload.type)).toEqual(['codev.gate.set']); + + // Down. The human approves, and a new gate opens. + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: approved\n approved_at: "2026-08-30T10:00:00.000Z"\n` + + ` pr:\n status: pending\n requested_at: "2026-08-30T11:00:00.000Z"\n`, + )); + + const after = recordingWriter(); + const second = watchOver(root, after); + try { + await second.publishNow(); + // ONE write, and it is the current state. Not a clear followed by a set, + // which would be a replay of the transition it never saw. + expect(after.calls).toHaveLength(1); + expect(after.calls[0].payload.type).toBe('codev.gate.set'); + expect(after.calls[0].payload.gate.gateName).toBe('pr'); + expect('revision' in after.calls[0].payload, 'the server allocates, the writer does not').toBe(false); + } finally { + second.close(); + } + }); + + /** + * A thread the server does not have is REPORTED, with the server's own reason. + * + * `status.yaml` can carry a `thread_id` from a thread that has since been + * deleted. The publisher does not go quiet about it and does not invent a + * different thread: it surfaces `CODEV_GATE_THREAD_NOT_FOUND`, which is the + * server saying the join key no longer resolves. + */ + it('surfaces an unresolvable thread rather than rendering nothing', async () => { + const root = workspace(); + const writer = recordingWriter(() => { + throw rpcFailure('CODEV_GATE_THREAD_NOT_FOUND', 'no thread thr-gone'); + }); + const logged: string[] = []; + writeStatus(root, '250-a-project', statusYaml( + ` plan-approval:\n status: pending\n requested_at: "2026-08-30T09:00:00.000Z"\n`, + 'thr-gone', + )); + const watch = startGateWatch({ + workspaceRoot: root, + writer, + log: (_level, message) => logged.push(message), + debounceMs: 5, + reconcileMs: 60_000, + }); + try { + const written = await watch.publishNow(); + expect(written).toHaveLength(1); + expect(written[0].outcome.kind).toBe('refused'); + expect(logged.join(' ')).toContain('CODEV_GATE_THREAD_NOT_FOUND'); + } finally { + watch.close(); + } + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-250-project-map.test.ts b/packages/codev/src/agent-farm/__tests__/spec-250-project-map.test.ts new file mode 100644 index 000000000..c82471631 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-250-project-map.test.ts @@ -0,0 +1,315 @@ +/** + * Spec 250, Phase 6 — the workspace-to-project map, and the gate writer's credential. + * + * The map's three answers are already held up by `issue-227-thread-seams.test.ts`, + * which drives `activeProjectForWorkspace` against a real HTTP server. This file + * does not re-test that. It tests the properties phase 6 ADDS, and the ones the + * plan asks to be stated rather than assumed: + * + * - the map is derived on connect and not cached across processes; + * - nothing derives a `projectId` from a path; + * - the gate-writer credential answers three ways, not two; + * - production actually reaches the gate watch, on its own connection. + * + * That last one is the discipline this spec has been caught by five times: a + * thing wired correctly in a test that production never builds. So the assertion + * is on the call site in `thread-backend.ts`, not on `startGateWatch` in isolation. + */ + +import { describe, it, expect } from 'vitest'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { readGateWriterToken } from '../thread-backend.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..', '..', '..'); +const threadBackendPath = join(repoRoot, 'packages/codev/src/agent-farm/thread-backend.ts'); +const threadBackendSource = readFileSync(threadBackendPath, 'utf8'); + +function scratch(label: string): string { + return mkdtempSync(join(tmpdir(), `spec250-map-${label}-`)); +} + +// ---------------------------------------------------------------- the map + +describe('spec 250: the project map is derived, not remembered', () => { + /** + * A restart RE-DERIVES rather than trusting stale state, and the way that is + * guaranteed is that there is nowhere for stale state to live. + * + * `projectId` is resolved inside `initialiseThreadBackend` from the server's own + * project list and held in the engine, which dies with the socket. If it were + * written to disk or to the database, a project deleted server-side would keep + * resolving for as long as the cache did — and a thread created against it + * would fail at `thread.create` with a message about a project, not about a + * cache. + */ + it('writes the resolved projectId to no store', () => { + // The resolution block, from the lookup to the engine registration. + const block = threadBackendSource.slice( + threadBackendSource.indexOf('const lookup = await activeProjectForWorkspace('), + threadBackendSource.indexOf('setThreadEngine(registered, key)'), + ); + expect(block.length, 'could not find the resolution block, so this would pass against anything') + .toBeGreaterThan(500); + for (const persistence of ['writeFileSync', 'upsert', 'db.', 'localStorage', 'mkdirSync']) { + expect(block, `the resolved projectId reaches ${persistence}`).not.toContain(persistence); + } + }); + + /** + * Two checkouts of the same repository are two workspaces, and a path is not + * stable across machines. So a `projectId` is read from the server's project + * list — never constructed from, or keyed by, a path. + * + * The canonical workspace key IS a path, and it is the lookup's INPUT: it + * answers "which project belongs to this root", which is a question the server + * decides. What must not happen is a path becoming an id. + */ + it('never builds a projectId out of a path', () => { + expect(threadBackendSource).not.toMatch(/projectId\s*[:=]\s*[^;\n]*(workspaceRoot|worktreePath|basename|canonicalWorkspaceKey)/); + // The lookup matches on the workspaceRoot INSIDE each project record, which + // is the server's own field, and compares it canonically rather than as a + // string — `/var` and `/private/var` are one directory on macOS. + expect(threadBackendSource).toContain('canonicalWorkspaceKey(project.workspaceRoot) === target'); + }); +}); + +// ---------------------------------------------------------------- credential + +describe('spec 250: the gate-writer credential answers three ways', () => { + it('reads not-configured when no path was named', () => { + expect(readGateWriterToken(undefined)).toEqual({ kind: 'not-configured' }); + expect(readGateWriterToken('')).toEqual({ kind: 'not-configured' }); + }); + + /** + * A named path that cannot be read is a FAULT, not "off". + * + * Someone said where the credential is. Reporting that as not-configured leaves + * every gate invisible with nothing said, which is the failure the whole + * "could not tell" rule exists for — and it is worse here than usual, because + * the symptom is a sidebar that looks fine. + */ + it('reads unreadable when the path was named and is absent', () => { + const dir = scratch('absent'); + try { + const result = readGateWriterToken(join(dir, 'nope.token')); + expect(result.kind).toBe('unreadable'); + if (result.kind !== 'unreadable') return; + expect(result.detail).toContain('ENOENT'); + // Names the likely cause, because "the file is not there" sends an operator + // looking at Codev when the answer is that the server has not started. + expect(result.detail).toContain('server'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * An empty file is a half-written credential, not an empty one. + * + * The fork writes to `.partial` and renames precisely so a reader never sees + * that — but a truncated bearer token authenticates like a revoked one, and + * would be reported as the server refusing us rather than as a local fault. + */ + it('reads unreadable rather than an empty token', () => { + const dir = scratch('empty'); + try { + const path = join(dir, 'gate-writer.token'); + writeFileSync(path, ' \n'); + const result = readGateWriterToken(path); + expect(result.kind).toBe('unreadable'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('trims the trailing newline the fork writes', () => { + const dir = scratch('token'); + try { + const path = join(dir, 'gate-writer.token'); + // Exactly what `writeCodevGateWriterToken` produces: the token, a newline, 0600. + writeFileSync(path, 'tok-abc123\n'); + chmodSync(path, 0o600); + expect(readGateWriterToken(path)).toEqual({ kind: 'token', token: 'tok-abc123' }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not put the token in the failure detail', () => { + const dir = scratch('leak'); + try { + const path = join(dir, 'gate-writer.token'); + writeFileSync(path, ''); + const result = readGateWriterToken(path); + if (result.kind !== 'unreadable') throw new Error('expected unreadable'); + // The file was empty, so there is nothing to leak here — the assertion that + // matters is on the code: the token is read into a local and reaches only + // the return value, never a message. + const fn = threadBackendSource.slice( + threadBackendSource.indexOf('export function readGateWriterToken'), + threadBackendSource.indexOf('/**', threadBackendSource.indexOf('export function readGateWriterToken')), + ); + expect(fn).not.toMatch(/detail:[^\n]*\$\{token\}/); + expect(fn).not.toMatch(/logger\.[a-z]+\([^)]*token/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// ---------------------------------------------------------------- call site + +/** + * ASSERT THE CALL SITE, NOT THE MODULE. + * + * `startGateWatch` has its own tests and they prove it publishes. None of them + * prove production ever calls it — which is exactly the shape of every defect + * this spec has produced: the schema guard wired to a layer nothing builds, the + * gate-writer credential with no production caller, the spawn factory that was + * never installed. + * + * These read `thread-backend.ts` and assert the wiring is there. Source + * assertions are a weaker tool than execution, and they are the right tool here: + * executing this path needs a live t3code server, a bootstrap exchange and two + * WebSockets, and a test that builds those itself would be supplying the very + * boundary whose absence is the risk. + */ +describe('spec 250: production reaches the gate watch', () => { + it('starts the watch inside the connection lifecycle', () => { + expect(threadBackendSource).toContain('startGateWatch({'); + const init = threadBackendSource.slice( + threadBackendSource.indexOf('async function initialiseThreadBackend'), + threadBackendSource.indexOf('const hangUp = new Map'), + ); + expect(init, 'the watch is not started inside initialiseThreadBackend').toContain('startGateWatch({'); + expect(init, 'the credential is not read where the watch is started').toContain('readGateWriterToken('); + }); + + /** + * On its OWN socket, with its OWN credential. + * + * The engine's dispatcher carries `orchestration:operate`. Routing gate writes + * over it would hand gate-writing to every holder of that scope, which is + * exactly what phase 4 gave `codev.gateWrite` a separate scope to prevent. The + * assertion is that the writer passed to the watch is NOT the engine's + * dispatcher. + */ + it('gives the watch a writer that is not the engine dispatcher', () => { + const call = threadBackendSource.slice( + threadBackendSource.indexOf('const watch = startGateWatch({'), + threadBackendSource.indexOf('gateWatches.set(key'), + ); + expect(call.length).toBeGreaterThan(100); + expect(call).toContain('writer: gateConnection.dispatcher'); + expect(call, 'gate writes must not travel on the orchestration:operate socket') + .not.toMatch(/writer:\s*dispatcher\b/); + // The second connection is opened with the gate credential, not by exchanging + // the bootstrap token again. + const connect = threadBackendSource.slice( + threadBackendSource.indexOf('const gateConnection = await connectDispatcher('), + threadBackendSource.indexOf('const watch = startGateWatch({'), + ); + expect(connect).toContain('credential.token'); + }); + + /** + * The first cycle runs on connect, not on the first file change. + * + * A gate that reached `pending` while this process was down would otherwise stay + * invisible until something touched `status.yaml` — which, for a gate waiting on + * a human, is exactly never. + */ + it('publishes once immediately rather than waiting for a change', () => { + expect(threadBackendSource).toContain('watch.publishNow()'); + }); + + /** + * Non-fatal, in all three shapes. + * + * A workspace whose gates do not publish is one where a human reads + * `status.yaml` instead of the sidebar. A workspace that cannot spawn is one + * where nothing runs. Making the first fatal trades the second for the first. + */ + it('does not make a gate-publishing failure fatal to spawning', () => { + const block = threadBackendSource.slice( + threadBackendSource.indexOf('const credential = readGateWriterToken('), + threadBackendSource.indexOf('hangUp.set(key, abandonConnection)'), + ); + expect(block.length).toBeGreaterThan(400); + expect(block, 'a gate-publishing failure must not throw out of initialiseThreadBackend') + .not.toMatch(/\bthrow new Error\b/); + expect(block).toContain('logger.warn'); + }); + + /** + * A RECONNECT must stop the previous watch, not overwrite the reference to it. + * + * Raised in review, and the teardown in `closeThreadBackend` does not cover it: + * a reconnect never goes through `closeThreadBackend`. `ensureThreadBackendReady` + * re-initialises a workspace whose engine was evicted — which is exactly what a + * t3code restart causes — so `gateWatches.set` alone drops the previous closer + * on the floor, leaking a live `fs.watch` AND a WebSocket per reconnect, in + * Tower, which runs for days. + * + * A source assertion for the same reason the rest of this block is one: + * executing the path needs a live server, a bootstrap exchange and two + * WebSockets, and a test that builds those itself would be supplying the very + * boundary whose absence is the risk. What it CAN do is fail when the stop + * disappears — verified by removing it. + */ + it('stops the previous watch before installing a new one', () => { + const block = threadBackendSource.slice( + threadBackendSource.indexOf('const credential = readGateWriterToken('), + threadBackendSource.indexOf('hangUp.set(key, abandonConnection)'), + ); + const stop = block.indexOf('gateWatches.get(key)?.()'); + const install = block.indexOf('gateWatches.set(key'); + expect(stop, 'a reconnect installs a second watch without stopping the first').toBeGreaterThan(-1); + expect(install).toBeGreaterThan(-1); + expect(stop, 'the previous watch is stopped after the new one is installed').toBeLessThan(install); + }); + + /** + * And the gate socket evicts its own entry when it closes. + * + * Nothing else will: this socket carries no engine, so the engine's close + * handler — which is what evicts everything else on the main connection — never + * sees it. Without this the map entry outlives its own connection, and a later + * `closeThreadBackend` closes a socket that is already gone while the watch it + * points at publishes into a dead wire. + */ + it('gives the gate socket a close handler that evicts its own entry', () => { + const connect = threadBackendSource.slice( + threadBackendSource.indexOf('const gateConnection = await connectDispatcher('), + threadBackendSource.indexOf('const watch = startGateWatch({'), + ); + expect(connect).toContain('gateWatches.delete(key)'); + expect(connect, 'the close handler must not evict a watch that replaced it') + .toContain('gateWatches.get(key) === stopThisWatch'); + }); + + /** + * Torn down with the backend, and BEFORE its early return. + * + * The watch is a separate socket and a live `fs.watch`, and it can exist on a + * workspace whose engine never registered — so it must not be cleaned up behind + * a guard that asks about the engine's socket. + */ + it('stops the watch on close, unconditionally', () => { + const close = threadBackendSource.slice( + threadBackendSource.indexOf('export function closeThreadBackend(workspaceRoot: string): void {'), + threadBackendSource.indexOf('deliberate.add(key)'), + ); + expect(close).toContain('stopGates?.()'); + expect( + close.indexOf('stopGates?.()'), + 'the watch is torn down after the early return, so a workspace with no engine leaks it', + ).toBeLessThan(close.indexOf('if (!close) return;')); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/spec-250-t3code-approval.e2e.test.ts b/packages/codev/src/agent-farm/__tests__/spec-250-t3code-approval.e2e.test.ts new file mode 100644 index 000000000..5a55a5b30 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/spec-250-t3code-approval.e2e.test.ts @@ -0,0 +1,474 @@ +/** + * Spec 250, phase 10 — a gate approved FROM t3code, through the fork's own proxy. + * + * ## Why this drives two real servers and calls nothing directly + * + * `agent-approval-path.test.ts` proved the ceremony is reachable over HTTP from a + * client holding nothing. This one adds the hop that phase 10 built: the fork's + * server, running its real `apps/server` build, forwarding to `codev-agent` on a + * path the browser never names an origin for. + * + * Every request here is one a page could make. The proxy is not imported, its + * functions are not called, and its route table is not consulted — the test + * reaches it the only way a browser can, so a route that is registered nowhere + * fails it. That is the standing rule about asserting the CALL SITE: the phase's + * unit tests can prove `forwardableHeaders` strips a header, and only this can + * prove anything is wired to `forwardableHeaders`. + * + * ## What each half is + * + * codev-agent an in-process `agent-routes` host on a random port, with a real + * workspace holding a real project at a pending gate. porch is the + * only writer of `status.yaml`; nothing here writes it. + * t3code the FORK's server, started by the harness on its own + * interpreter, configured with an allowlist naming that port. + * + * ## Skips, never passes + * + * A missing fork checkout, a missing interpreter, a server that would not start — + * each is "this run could not tell you anything". Reported as a skip with the + * reason, because a green tick over an un-run ceremony is the worst answer + * available. + */ + +import { afterAll, beforeAll, describe, expect, it, type TestContext } from 'vitest'; +import { readFileSync } from 'node:fs'; +import * as yaml from 'js-yaml'; +import { + HUMAN_SESSION_HEADER, + MACHINE_CREDENTIAL_HEADER, + PAIRING_TOKEN_HEADER, +} from '../servers/agent-auth.js'; +import { + AGENT_MACHINE, + MACHINE_MINT, + SESSION_MINT, + startAgentHost, + type AgentHost, +} from '../../__tests__/e2e/spec-250-agent-host.js'; +import { startForkServer, stopForkStack } from '../../__tests__/e2e/spec-250-fork-stack.js'; + +/** The env var the fork's server reads its codev-agent allowlist from. */ +const ORIGINS_ENV = 'T3CODE_CODEV_AGENT_ORIGINS'; +/** The proxy prefix, as the browser spells it. Same-origin: a path, never a URL. */ +const PROXY_PREFIX = '/api/codev/agent'; +const TARGET_ID = 'local'; + +const BUILDER_ID = 'spir-250'; +const PROJECT_ID = '250'; +const GATE_NAME = 'pr'; + +let agent: AgentHost | null = null; +let forkBase: string | null = null; +let accessToken: string | null = null; +let unavailable: string | null = null; +let previousOrigins: string | undefined; + +beforeAll(async () => { + agent = await startAgentHost({ + builders: [{ id: BUILDER_ID, threadId: 'thread-1', projectId: PROJECT_ID, gateName: GATE_NAME }], + }); + // Configured BEFORE the fork server starts, and through its real environment: + // the harness spawns it with `process.env`, which is the operator's own path + // to this setting rather than a back door only a test can use. + previousOrigins = process.env[ORIGINS_ENV]; + process.env[ORIGINS_ENV] = `${TARGET_ID}=${agent.origin}`; + const started = await startForkServer(); + if (!started.available) { + unavailable = started.reason; + return; + } + forkBase = started.serverBase; + accessToken = started.accessToken; +}, 180_000); + +afterAll(() => { + stopForkStack(); + agent?.stop(); + if (previousOrigins === undefined) delete process.env[ORIGINS_ENV]; + else process.env[ORIGINS_ENV] = previousOrigins; +}); + +/** A request the PAGE could make: a path on t3code's origin, plus its session. */ +function proxied(path: string): string { + return `${forkBase}${PROXY_PREFIX}/${TARGET_ID}${path}`; +} + +function browserHeaders(extra: Record = {}): Record { + return { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + accept: 'application/json', + ...extra, + }; +} + +async function post( + url: string, + headers: Record, + body: unknown, +): Promise<{ status: number; body: Record }> { + const response = await fetch(url, { + method: 'POST', + headers: browserHeaders(headers), + body: JSON.stringify(body ?? {}), + redirect: 'manual', + }); + return { status: response.status, body: await response.json().catch(() => ({})) }; +} + +/** + * A RUN THAT COULD NOT HAPPEN MUST NOT REPORT GREEN. + * + * Review finding, and it is this file's own header turned against it. The first + * version logged a warning and RETURNED, which vitest records as a **pass** — so + * criterion 4 and the SSRF refusals reported success on a run where the fork + * server never started and not one assertion executed. That is "I could not tell" + * spelled as "yes", on the phase's own acceptance criterion, which is worse than + * the failure it was trying to avoid. + * + * `ctx.skip` marks the test skipped and does not return, so the body below is + * unreachable rather than merely unexecuted. The Playwright spec beside it + * already did this with `test.skip`; the two now agree. + */ +function skipIfUnavailable(ctx: TestContext): void { + if (unavailable === null) return; + ctx.skip(`spec-250 t3code approval: ${unavailable}`); +} + +function statusYaml(): any { + return yaml.load(readFileSync(agent!.statusPathFor(BUILDER_ID), 'utf8')); +} + +describe('spec 250 phase 10: approving a gate from t3code', () => { + /** + * CRITERION 4, end to end, over the proxy. + * + * Every step is a request to t3code's origin. The page never names + * `codev-agent`; it names a configured id, and the server holds the origin. + */ + it('walks the ceremony through the proxy and porch records who approved', async (ctx) => { + skipIfUnavailable(ctx); + + // 1. PAIR. The one agent route reachable with no machine credential — and it + // still needs a token, so t3code's session bought entry to the proxy and + // nothing more. + const machineToken = agent!.pairings.issue(MACHINE_MINT).token; + const paired = await post( + proxied('/api/agent/v1/pairing/redeem'), + { [PAIRING_TOKEN_HEADER]: machineToken }, + { machine: 'ipad' }, + ); + expect(paired.status).toBe(201); + const credential: string = paired.body.credential; + expect(typeof credential).toBe('string'); + + // 2. A MACHINE CREDENTIAL IS NOT A HUMAN SESSION. Refused, and refused + // differently from a caller with no credential at all — asserted below. + const beforeSession = await fetch(proxied('/api/agent/v1/session'), { + headers: browserHeaders({ [MACHINE_CREDENTIAL_HEADER]: credential }), + }); + expect(beforeSession.status).toBe(401); + + // 3. OPEN A SESSION. A second, distinct single-use token. + const sessionToken = agent!.pairings.issue(SESSION_MINT).token; + const session = await post( + proxied('/api/agent/v1/human-sessions'), + { [MACHINE_CREDENTIAL_HEADER]: credential, [PAIRING_TOKEN_HEADER]: sessionToken }, + {}, + ); + expect(session.status).toBe(201); + const presentation: string = session.body.presentation; + + const authed = { + [MACHINE_CREDENTIAL_HEADER]: credential, + [HUMAN_SESSION_HEADER]: presentation, + }; + + // 4. CAPABILITY, issued for the HOST that will verify it. + const capability = await post(proxied('/api/agent/v1/approval-capabilities'), authed, { + principalKind: 'human-client', + }); + expect(capability.status).toBe(201); + expect(capability.body.machine).toBe(AGENT_MACHINE); + + // 5. NONCE, bound to this one gate. + const nonce = await post(proxied('/api/agent/v1/approval-nonces'), authed, { + projectId: PROJECT_ID, + gateName: GATE_NAME, + capabilityId: capability.body.capabilityId, + }); + expect(nonce.status).toBe(201); + + // 6. APPROVE. porch is the only writer. + const approved = await post( + proxied(`/api/agent/v1/workspaces/${agent!.encodedWorkspace}/gates/approve`), + authed, + { + projectId: PROJECT_ID, + gateName: GATE_NAME, + capability: capability.body.presentation, + nonce: nonce.body.nonce, + }, + ); + expect(approved.status).toBe(200); + expect(approved.body.signal).toBe('GATE_APPROVED'); + + // 7. THE THREE FIELDS CRITERION 4 NAMES, in the real status.yaml, written by + // porch. The response body carried them too — and the client reads them + // from there rather than from its own clock, which is what the unit test + // named "server-sourced" holds. + const state = statusYaml(); + expect(state.gates[GATE_NAME].status).toBe('approved'); + expect(state.gates[GATE_NAME].approval.session_id).toBe(session.body.sessionId); + expect(state.gates[GATE_NAME].approval.machine).toBe(AGENT_MACHINE); + expect(typeof state.gates[GATE_NAME].approved_at).toBe('string'); + expect(Number.isNaN(Date.parse(state.gates[GATE_NAME].approval.approved_at))).toBe(false); + // And the SERVER said the same three things, so a page that reports them is + // reporting the record rather than reconstructing it. + expect(typeof approved.body.approvedAt).toBe('string'); + expect(approved.body.machine).toBe(AGENT_MACHINE); + expect(approved.body.sessionId).toBe(session.body.sessionId); + }, 180_000); + + /** + * THE TWO REFUSALS ARE NOT SPELLED THE SAME WAY. + * + * A caller with no machine credential and one with a credential but no human + * session need different next actions — pair, versus open a session — and a + * single refusal for both leaves a human with nowhere to go. + */ + it('refuses a missing machine credential and a missing human session differently', async (ctx) => { + skipIfUnavailable(ctx); + + const noCredential = await fetch(proxied('/api/agent/v1/session'), { + headers: browserHeaders(), + }); + expect(noCredential.status).toBe(401); + const noCredentialBody = (await noCredential.json()) as { signal: string }; + expect(noCredentialBody.signal).toBe('MACHINE_CREDENTIAL_REQUIRED'); + + const machineToken = agent!.pairings.issue(MACHINE_MINT).token; + const paired = await post( + proxied('/api/agent/v1/pairing/redeem'), + { [PAIRING_TOKEN_HEADER]: machineToken }, + { machine: 'laptop' }, + ); + const noSession = await post( + proxied('/api/agent/v1/approval-capabilities'), + { [MACHINE_CREDENTIAL_HEADER]: paired.body.credential }, + { principalKind: 'human-client' }, + ); + expect(noSession.status).toBe(401); + expect(noSession.body.signal).toBe('HUMAN_SESSION_REQUIRED'); + expect(noSession.body.signal).not.toBe(noCredentialBody.signal); + }, 120_000); + + /** + * `afx pair revoke ` stops THAT browser and nothing else. + * + * Revoked mid-life, over the proxy, and a second machine paired afterwards + * still reads — so this is a per-machine withdrawal rather than a lockout. + */ + it('stops a revoked machine and leaves every other one working', async (ctx) => { + skipIfUnavailable(ctx); + + const statePath = proxied(`/api/agent/v1/workspaces/${agent!.encodedWorkspace}/state`); + + const revokedToken = agent!.pairings.issue(MACHINE_MINT).token; + const revokedPair = await post( + proxied('/api/agent/v1/pairing/redeem'), + { [PAIRING_TOKEN_HEADER]: revokedToken }, + { machine: 'doomed' }, + ); + const doomed: string = revokedPair.body.credential; + + const keptToken = agent!.pairings.issue(MACHINE_MINT).token; + const keptPair = await post( + proxied('/api/agent/v1/pairing/redeem'), + { [PAIRING_TOKEN_HEADER]: keptToken }, + { machine: 'kept' }, + ); + const kept: string = keptPair.body.credential; + + const before = await fetch(statePath, { + headers: browserHeaders({ [MACHINE_CREDENTIAL_HEADER]: doomed }), + }); + expect(before.status).toBe(200); + + expect(agent!.machines.revoke('doomed')).toBe(true); + + const after = await fetch(statePath, { + headers: browserHeaders({ [MACHINE_CREDENTIAL_HEADER]: doomed }), + }); + expect(after.status).toBe(403); + const afterBody = (await after.json()) as { signal: string }; + // Withdrawn, not unknown. "Never paired" would send an operator to pair + // again over a decision someone deliberately made. + expect(afterBody.signal).toBe('MACHINE_CREDENTIAL_REVOKED'); + + const other = await fetch(statePath, { + headers: browserHeaders({ [MACHINE_CREDENTIAL_HEADER]: kept }), + }); + expect(other.status).toBe(200); + }, 120_000); + + /** + * SSRF. The browser names a configured id and never a host. + * + * Each of these is refused BY THE SERVER — the page declining to ask would be + * no control at all, since the request under test is one a page can be made to + * issue. A route-path allowlist does not constrain the host, which is why the + * allowlist is over ORIGINS and the browser selects among them. + */ + it('refuses a URL, an unconfigured target and an uncarried path, server-side', async (ctx) => { + skipIfUnavailable(ctx); + + // A URL where a path belongs. Refused as a URL, not normalised into one. + const asUrl = await fetch( + `${forkBase}${PROXY_PREFIX}/${TARGET_ID}/http://169.254.169.254/latest/meta-data`, + { headers: browserHeaders(), redirect: 'manual' }, + ); + expect(asUrl.status).toBe(400); + expect(((await asUrl.json()) as { signal: string }).signal).toBe('CODEV_AGENT_PATH_ABSOLUTE'); + + // A loopback address that is not the configured one, named as a target id. + // There is no entry for it, so there is no origin to dial. + const unconfigured = await fetch( + `${forkBase}${PROXY_PREFIX}/http%3A%2F%2F127.0.0.1%3A9/api/agent/v1/session`, + { headers: browserHeaders(), redirect: 'manual' }, + ); + expect(unconfigured.status).toBe(404); + expect(((await unconfigured.json()) as { signal: string }).signal).toBe( + 'CODEV_AGENT_UNKNOWN_TARGET', + ); + + // A real agent route the table deliberately does not carry: the SSE stream, + // which this proxy buffers and therefore must not pretend to serve. + const stream = await fetch( + proxied(`/api/agent/v1/workspaces/${agent!.encodedWorkspace}/stream`), + { headers: browserHeaders(), redirect: 'manual' }, + ); + expect(stream.status).toBe(404); + expect(((await stream.json()) as { signal: string }).signal).toBe('CODEV_AGENT_PATH_NOT_ALLOWED'); + + // Revocation over HTTP is not carried either: a browser that could revoke + // could deny a human their own gate. `afx pair revoke` is the operator path. + const revoke = await fetch(proxied('/api/agent/v1/machines/ipad'), { + method: 'DELETE', + headers: browserHeaders(), + redirect: 'manual', + }); + expect(revoke.status).toBe(404); + + // And a path outside the agent prefix entirely. + const elsewhere = await fetch(proxied('/api/orchestration/threads'), { + headers: browserHeaders(), + redirect: 'manual', + }); + expect(elsewhere.status).toBe(404); + }, 120_000); + + /** + * THE PROXY BUFFERS, SO IT MUST BE BOUNDED. + * + * `HttpServerRequest.MaxBodySize` defaults to UNBOUNDED in Effect, and this + * route reads the whole body before forwarding it — so without a cap one + * authenticated caller makes the server hold an arbitrary amount in memory, on + * the route whose whole purpose is to be reachable from a phone. + * + * Two paths, and both are driven: a body that DECLARES an oversize + * `content-length` is refused before it is read, and a chunked body that + * declares no length is caught by the cap on the read itself. Asserted here + * rather than in a unit test because the bound lives in the route handler, and + * a unit test of the pure functions cannot see whether anything applies it. + */ + it('refuses an oversize body, declared or chunked, rather than buffering it', async (ctx) => { + skipIfUnavailable(ctx); + + const oversize = 'x'.repeat(200_000); + + const declared = await fetch(proxied('/api/agent/v1/pairing/redeem'), { + method: 'POST', + headers: browserHeaders({ [PAIRING_TOKEN_HEADER]: 'unused' }), + body: JSON.stringify({ machine: oversize }), + redirect: 'manual', + }); + expect(declared.status).toBe(413); + expect(((await declared.json()) as { signal: string }).signal).toBe('CODEV_AGENT_BODY_TOO_LARGE'); + + // Chunked: a ReadableStream body declares no content-length, so the early + // check cannot see it and the cap on the read is what answers. + const chunked = await fetch(proxied('/api/agent/v1/pairing/redeem'), { + method: 'POST', + headers: browserHeaders({ [PAIRING_TOKEN_HEADER]: 'unused' }), + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify({ machine: oversize }))); + controller.close(); + }, + }), + // Node's fetch requires this for a stream body. + duplex: 'half', + redirect: 'manual', + } as RequestInit & { duplex: 'half' }); + expect(chunked.status).toBe(413); + expect(((await chunked.json()) as { signal: string }).signal).toBe('CODEV_AGENT_BODY_UNREAD'); + + // And an ordinary body still goes through, so the cap is a bound rather than + // a wall — without this the two above pass on a proxy that refuses every POST. + const machineToken = agent!.pairings.issue(MACHINE_MINT).token; + const ordinary = await post( + proxied('/api/agent/v1/pairing/redeem'), + { [PAIRING_TOKEN_HEADER]: machineToken }, + { machine: 'within-the-bound' }, + ); + expect(ordinary.status).toBe(201); + }, 120_000); + + /** + * t3code's own session does not travel to `codev-agent`. + * + * The request below carries a valid t3code bearer and NO machine credential. + * If the proxy forwarded `authorization`, `codev-agent` would see a bearer it + * does not understand — and, more to the point, another server would have + * t3code's identity. The refusal proves the header did not arrive as anything + * `codev-agent` could act on, and the header-level assertion lives in the + * fork's own unit test. + */ + it('does not hand t3code\'s session to codev-agent', async (ctx) => { + skipIfUnavailable(ctx); + const response = await fetch(proxied('/api/agent/v1/session'), { headers: browserHeaders() }); + expect(response.status).toBe(401); + expect(((await response.json()) as { signal: string }).signal).toBe( + 'MACHINE_CREDENTIAL_REQUIRED', + ); + }, 60_000); + + /** + * The proxy is not an open hop. Without t3code's own session it refuses before + * anything is dialled — otherwise this server would forward to a loopback + * service for anyone who can reach it. + */ + it('refuses an unauthenticated caller before dialling anything', async (ctx) => { + skipIfUnavailable(ctx); + const response = await fetch(proxied('/api/agent/v1/session'), { + headers: { accept: 'application/json' }, + redirect: 'manual', + }); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + }, 60_000); + + /** The targets route names ids and never origins. */ + it('publishes target ids without their origins', async (ctx) => { + skipIfUnavailable(ctx); + const response = await fetch(`${forkBase}/api/codev/agent-targets`, { + headers: browserHeaders(), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { targets: ReadonlyArray> }; + expect(body.targets).toEqual([{ id: TARGET_ID }]); + expect(JSON.stringify(body)).not.toContain(String(agent!.port)); + }, 60_000); +}); diff --git a/packages/codev/src/agent-farm/commands/spawn.ts b/packages/codev/src/agent-farm/commands/spawn.ts index 064b88174..04840317e 100644 --- a/packages/codev/src/agent-farm/commands/spawn.ts +++ b/packages/codev/src/agent-farm/commands/spawn.ts @@ -36,6 +36,7 @@ import { chooseSpawnPath, } from '../db/thread-identity.js'; import { ensureThreadBackendReady } from '../thread-backend.js'; +import { threadIdForAgent } from '../thread-runtime.js'; import { findStatusPath, getStatusPath, recordThreadId } from '../../commands/porch/state.js'; import { DEFAULT_ARCHITECT_NAME } from '../utils/architect-name.js'; @@ -133,6 +134,52 @@ function logSpawnSuccess( if (identity.terminalId) logger.kv('Terminal', client.getTerminalWsUrl(identity.terminalId)); } +/** + * Where this builder sits in Workspace > Architect > Builders (spec 250). + * + * TWO ANSWERS, and `unowned` carries its own reason rather than splitting into a + * third case. Review caught an earlier version of this comment claiming three and + * then listing `unowned` twice, which is the kind of drift a doc acquires when the + * union changes under it. + * + * owned the spawning architect is thread-backed; the builder gets + * `role: "builder"` and that architect's thread id. + * unowned no architect thread to name, with `detail` saying which of the + * three ways that happened: no workspace root was given, the + * architect is not registered, or it is registered and running on + * a terminal rather than a thread. The last is an ordinary + * configuration today. The builder gets NEITHER field, exactly as + * before this spec, and the reason is reported rather than left to + * be inferred from a thread that quietly has no role. + * + * Sending `role: "builder"` without a parent is not an option in any of them: + * `DriverThread.create` refuses it before dispatch, and the server refuses it + * after. A builder is owned by definition, so "builder with nobody" is not a + * weaker claim than "no role" — it is an invalid one. + */ +export type ThreadHierarchy = + | { readonly kind: 'owned'; readonly role: 'builder'; readonly parentThreadId: string } + | { readonly kind: 'unowned'; readonly detail: string }; + +export function resolveThreadHierarchy( + workspaceRoot: string | undefined, + architectName: string, +): ThreadHierarchy { + if (!workspaceRoot) { + return { kind: 'unowned', detail: 'no workspace root was named for this spawn' }; + } + const parentThreadId = threadIdForAgent(workspaceRoot, architectName, 'architect'); + if (!parentThreadId) { + return { + kind: 'unowned', + detail: + `architect "${architectName}" has no thread in ${workspaceRoot} — it is either not ` + + `registered or is running on a terminal rather than a thread`, + }; + } + return { kind: 'owned', role: 'builder', parentThreadId }; +} + export async function launchSpawnedBuilder(opts: { existing?: { terminalId?: string; threadId?: string } | null; builderId: string; @@ -167,6 +214,21 @@ export async function launchSpawnedBuilder(opts: { // — and which never sees a real workspace's factory, deliberately (issue #227 item 1). const pathKind = chooseSpawnPath(opts.existing ?? undefined, opts.workspaceRoot); if (pathKind === 'thread') { + // Resolved HERE, not at the six call sites. Every one of them already passes + // `spawnedByArchitect: SPAWNING_ARCHITECT_NAME` to `persistSpawnedBuilder`, + // so threading the parent through each would be the same fact computed six + // times — and the seventh call site would be the one that forgot. + const hierarchy = resolveThreadHierarchy(opts.workspaceRoot, SPAWNING_ARCHITECT_NAME); + if (hierarchy.kind === 'unowned') { + // Said out loud. A thread with no role renders in the sidebar as a thread + // with no place in the tree, and "this builder has no architect above it" + // is a fact an operator can act on, while an empty group is one they have + // to go and diagnose. + logger.info( + `Thread-backed spawn of ${opts.builderId} carries no hierarchy: ${hierarchy.detail}. ` + + `The thread is created without a role or a parent, as it was before spec 250.`, + ); + } const threadId = opts.existing?.threadId ?? await allocateSpawnThread({ builderId: opts.builderId, worktreePath: opts.worktreePath, @@ -175,6 +237,9 @@ export async function launchSpawnedBuilder(opts: { model: opts.model, prompt: opts.prompt, launchScript: opts.launchScript, + ...(hierarchy.kind === 'owned' + ? { role: hierarchy.role, parentThreadId: hierarchy.parentThreadId } + : {}), roleContent: opts.roleContent, roleFilePath: opts.roleFilePath, }, opts.workspaceRoot); diff --git a/packages/codev/src/agent-farm/db/thread-identity.ts b/packages/codev/src/agent-farm/db/thread-identity.ts index e250e0651..311e959e5 100644 --- a/packages/codev/src/agent-farm/db/thread-identity.ts +++ b/packages/codev/src/agent-farm/db/thread-identity.ts @@ -56,6 +56,20 @@ export type SpawnThreadFactory = (input: { prompt?: string; launchScript?: string; role?: 'builder' | 'architect'; + /** + * Spec 250. The architect thread this builder hangs off. + * + * Separate from `role` because the two travel together and can each be absent + * for a different reason: `role` says what this thread IS, `parentThreadId` + * says who owns it. A builder with a role and no parent is refused before + * dispatch (`HierarchyRefusedError`), which is why the caller must resolve the + * architect's thread id rather than letting the server discover the gap. + * + * Absent when the spawning architect is not itself thread-backed. That is a + * real configuration, not an error, and it is reported rather than inferred — + * see `resolveThreadHierarchy` in `commands/spawn.ts`. + */ + parentThreadId?: string | null; /** * The role prompt, and where the PTY path writes it. * diff --git a/packages/codev/src/agent-farm/porch-thread-engine.ts b/packages/codev/src/agent-farm/porch-thread-engine.ts index 568ebbb2b..14b47acf8 100644 --- a/packages/codev/src/agent-farm/porch-thread-engine.ts +++ b/packages/codev/src/agent-farm/porch-thread-engine.ts @@ -164,6 +164,23 @@ export function createPorchThreadEngine(options: PorchThreadEngineOptions): Thre defaultModel: options.defaultModel, worktreePath: input.worktreePath, branch: input.branch, + /** + * Spec 250. Forwarded, and OMITTED rather than nulled when absent. + * + * `input.role` is `'builder' | 'architect' | undefined`, and undefined + * is a real third case: a caller that did not name a role gets a thread + * with none, exactly as before this spec. Writing `?? null` here would + * turn "not told" into "decided", and `DriverThread.create` would then + * send `role: null` on a payload it was never given a role for. + * + * `parentThreadId` is deliberately NOT defaulted either. A builder + * whose architect is not thread-backed arrives here with both fields + * absent, and `DriverThread.create` accepts that — it is an unowned + * thread, which is what the old behaviour produced. What it will not + * accept is a role without its parent. + */ + ...(input.role === undefined ? {} : { role: input.role }), + ...(input.parentThreadId === undefined ? {} : { parentThreadId: input.parentThreadId }), // The PTY path injects a role through harness-specific script fragments and // env; a thread has none of that, and `DriverThread` already carries a role // into the first turn. Forwarded rather than reimplemented. diff --git a/packages/codev/src/agent-farm/servers/t3-gate-publisher.ts b/packages/codev/src/agent-farm/servers/t3-gate-publisher.ts new file mode 100644 index 000000000..2301435c2 --- /dev/null +++ b/packages/codev/src/agent-farm/servers/t3-gate-publisher.ts @@ -0,0 +1,548 @@ +/** + * Spec 250, Phase 6 — porch gate state published onto the fork's thread record. + * + * Spec 146 wrote the gate name into the thread TITLE, because t3code had nowhere + * else to put it. Phase 4 built the nowhere-else; this is what fills it. + * + * ## `status.yaml` is authoritative, always + * + * The block on the thread is a PROJECTION of `status.yaml` and never a second + * copy of the truth. Any disagreement is resolved by re-reading `status.yaml` — + * never by trusting what was last published, and never by reading the thread back + * and reconciling. That direction is one-way on purpose: a projection that can + * write back to its source is a second source. + * + * ## The publisher invents no revision + * + * `codev.gate.set` takes an OPTIONAL `revision`, and this module never sends one. + * The server allocates `gateRevision + 1` and returns it. A counter held in a + * writer's memory resets when the writer restarts, and a reset counter makes + * every later write stale — which renders as "no gate pending" exactly where a + * human is waiting. Not sending one is not laziness about idempotency; it is the + * only way a restart cannot lie. + * + * The corollary is that on reconnect this republishes CURRENT state rather than + * replaying what it saw while it was away. There is no history to replay: the + * gate a human needs to see is whatever `status.yaml` says right now. + * + * ## Losing the question is better than losing the gate + * + * Codev bounds a gate request in BYTES (`GATE_REQUEST_LIMITS`); the fork bounds + * `CodevGate` in string length. They are different limits, and the fork's are + * tighter — a 1024-byte question is accepted by porch and can exceed the fork's + * 500-character cap. The fork refuses an oversize gate WHOLE, because a gate that + * partially applied would leave a human looking at half a question. + * + * So this module does the narrowing, and it narrows the OPTIONAL content only. + * `gateName` and `requestedAt` are what say "a human is needed"; the question and + * choices are what make the decision easier. Dropping the second to keep the first + * is right, and dropping both because the second did not fit is not. Every drop is + * named in the projection so the caller can log which content did not travel — + * silent truncation would let a human read a shortened question as the whole one. + */ + +import type { GateRequest } from '@cluesmith/codev-types'; +import { watchAgentState, type StateSubscription } from './agent-state-stream.js'; +import { readWorkspaceStatuses, type PorchStatusProjection, type StatusReadResult } from './status-reader.js'; + +/** The RPC the fork exposes for gate writes. Its own method, with its own scope. */ +export const GATE_WRITE_METHOD = 'codev.gateWrite'; + +/** + * The fork's `CodevGate` bounds, in string length. + * + * Copied from `packages/contracts/src/orchestration.ts` in the fork rather than + * imported: the vendored contract carries the JSON Schema, and the emitter drops + * the `TrimmedNonEmptyString` checks behind its transform (`generated/LOSSY.md`), + * so the caps are not readable from the artifacts either. A test reads them back + * out of the generated schema where they survive, and out of the fork source when + * the checkout is present, so the copy is checked rather than trusted. + */ +export const T3_GATE_LIMITS = Object.freeze({ + gateName: 120, + question: 500, + label: 200, + consequence: 2000, + terminalExcerpt: 8000, + minChoices: 1, + maxChoices: 5, +}); + +export interface T3GateChoice { + readonly label: string; + readonly consequence: string; + readonly recommended?: boolean; +} + +/** The `CodevGate` payload, as this repository constructs it. */ +export interface T3Gate { + readonly gateName: string; + readonly requestedAt: string; + readonly question?: string; + readonly choices?: ReadonlyArray; + readonly terminalExcerpt?: string; +} + +/** + * What `status.yaml` says the thread's gate block should be. + * + * `dropped` names optional content that did not fit the fork's bounds. It is + * empty on the ordinary path and is never a failure: the gate still publishes. + */ +export type GateProjection = + | { readonly kind: 'set'; readonly gate: T3Gate; readonly dropped: ReadonlyArray } + | { readonly kind: 'clear' }; + +/** True when two projections would produce the same thing on the thread. */ +export function sameProjection(a: GateProjection, b: GateProjection): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'clear' || b.kind === 'clear') return true; + return JSON.stringify(a.gate) === JSON.stringify(b.gate); +} + +function fits(value: string, limit: number): boolean { + // `.length`, matching Effect's `isMaxLength`, which counts UTF-16 code units. + // Counting bytes here would refuse content the server accepts, and counting + // code POINTS would accept content it refuses — an emoji is one code point and + // two units. The check has to be the server's check or it is a different check. + return value.length <= limit; +} + +/** A single-line rendering: the fork refuses a multi-line question outright. */ +function singleLine(value: string): string { + return value.replace(/[\r\n]+/g, ' ').trim(); +} + +/** + * Which gate a human is actually waiting on. + * + * `status.yaml` holds every gate the project has ever had, approved ones + * included. A thread carries ONE block, so this picks the pending gate with the + * earliest `requested_at` — earliest rather than latest, because when two are + * somehow pending the older one is the one that has been waiting. + * + * Ties, and gates with no timestamp, fall back to gate NAME order. Not because + * name order is meaningful, but because an arbitrary-but-stable choice publishes + * the same gate on every cycle, and an unstable one makes the block flicker + * between two gates for as long as both are pending. + */ +export function pendingGate( + status: PorchStatusProjection, +): { readonly name: string; readonly requestedAt: string | undefined; readonly request?: GateRequest } | null { + const pending = Object.entries(status.gates) + .filter(([, gate]) => gate.status === 'pending') + .map(([name, gate]) => ({ name, requestedAt: gate.requested_at, request: gate.request })); + if (pending.length === 0) return null; + pending.sort((a, b) => { + const at = a.requestedAt ?? ''; + const bt = b.requestedAt ?? ''; + if (at !== bt) { + // A gate with no timestamp sorts LAST, not first. An empty string would + // otherwise win every comparison and make an untimestamped gate outrank a + // real one that has been waiting for a day. + if (at === '') return 1; + if (bt === '') return -1; + return at < bt ? -1 : 1; + } + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + return pending[0]; +} + +/** + * Project one status file into the block the thread should carry. + * + * Pure. It reads `status.yaml`'s projection and returns what to publish; it does + * not know about sockets, revisions, or what was published before. + */ +export function projectGate(status: PorchStatusProjection, now: () => string = () => new Date().toISOString()): GateProjection { + const pending = pendingGate(status); + if (pending === null) return { kind: 'clear' }; + + const dropped: string[] = []; + + // `gateName` is the one field with no fallback. A name too long for the fork + // cannot be shortened without changing which gate it names, so this reports the + // gate as clear rather than publishing a gate under a name that is not its own. + // It has never happened — porch gate names are short words — and a silent + // truncation here would be a gate a human cannot match to their protocol. + if (!fits(pending.name, T3_GATE_LIMITS.gateName) || pending.name.trim() === '') { + return { kind: 'clear' }; + } + + const gate: { + gateName: string; + requestedAt: string; + question?: string; + choices?: T3GateChoice[]; + terminalExcerpt?: string; + } = { + gateName: pending.name, + // A gate with no recorded timestamp still needs one on the wire: `requestedAt` + // is required. "Now" is the honest substitute — it says the gate is pending as + // of this publish, which is exactly what is known. + requestedAt: pending.requestedAt ?? now(), + }; + + const request = pending.request; + if (request) { + const question = singleLine(request.question ?? ''); + if (question !== '' && fits(question, T3_GATE_LIMITS.question)) { + gate.question = question; + } else if (question !== '') { + dropped.push(`question (${question.length} chars over the ${T3_GATE_LIMITS.question} limit)`); + } + + const choices = (request.choices ?? []).filter( + (choice) => + fits(choice.label, T3_GATE_LIMITS.label) && fits(choice.consequence, T3_GATE_LIMITS.consequence), + ); + const overlong = (request.choices ?? []).length - choices.length; + if (overlong > 0) dropped.push(`${overlong} choice(s) whose label or consequence was over the limit`); + + if (choices.length >= T3_GATE_LIMITS.minChoices) { + // Truncating to the cap keeps the first N in the order porch recorded them, + // which is the order the human was meant to read. + const kept = choices.slice(0, T3_GATE_LIMITS.maxChoices); + if (kept.length < choices.length) { + dropped.push(`${choices.length - kept.length} choice(s) past the ${T3_GATE_LIMITS.maxChoices}-choice limit`); + } + // At most one recommendation. Two is not two recommendations, it is none, + // and the fork refuses the whole gate for it — so the SECOND is demoted + // rather than the gate lost. Recorded, because a choice quietly losing its + // recommendation is a changed answer. + let seenRecommended = false; + gate.choices = kept.map((choice) => { + if (choice.recommended !== true) return { label: choice.label, consequence: choice.consequence }; + if (seenRecommended) { + dropped.push(`the "recommended" mark on "${choice.label}" (only one choice may carry it)`); + return { label: choice.label, consequence: choice.consequence }; + } + seenRecommended = true; + return { label: choice.label, consequence: choice.consequence, recommended: true }; + }); + } else if ((request.choices ?? []).length > 0) { + dropped.push('every choice was over the limit, so none could be published'); + } + + if (request.terminalExcerpt !== undefined && request.terminalExcerpt !== '') { + if (fits(request.terminalExcerpt, T3_GATE_LIMITS.terminalExcerpt)) { + gate.terminalExcerpt = request.terminalExcerpt; + } else { + // Kept, tail-first, because the end of a terminal excerpt is the part + // that says what happened. A marker is prepended so nobody reads the + // fragment as the whole output — that is the "could not tell" rule + // applied to a payload rather than to a signal. + const marker = '[…truncated for the thread gate block; see the worktree for the full output]\n'; + gate.terminalExcerpt = + marker + request.terminalExcerpt.slice(-(T3_GATE_LIMITS.terminalExcerpt - marker.length)); + dropped.push( + `${request.terminalExcerpt.length - T3_GATE_LIMITS.terminalExcerpt + marker.length} chars from the head of terminalExcerpt`, + ); + } + } + } + + return { kind: 'set', gate, dropped }; +} + +/** What `publishGate` needs of a transport. Injected, so no socket is required to test it. */ +export interface GateWriter { + call(method: string, payload: unknown): Promise; +} + +/** + * The outcome of one gate write. + * + * THREE ANSWERS. `unconfirmed` is not `refused` and neither is `applied`: + * + * applied the server answered with a revision. The write landed. + * refused the server said no, and named a reason. Settled — retrying + * replays a decision that was already made. + * unconfirmed the transport failed, or the response could not be read. The + * write may or may not have landed, and the caller must not + * render either. The fork spells this `CODEV_GATE_WRITE_UNCONFIRMED` + * for the same reason. + */ +export type GateWriteOutcome = + | { readonly kind: 'applied'; readonly gateRevision: number; readonly cleared: boolean } + | { readonly kind: 'refused'; readonly reason: string; readonly detail: string } + | { readonly kind: 'unconfirmed'; readonly detail: string }; + +function readResult(value: unknown): GateWriteOutcome { + const result = value as { threadId?: unknown; gateRevision?: unknown; cleared?: unknown } | null; + if ( + !result + || typeof result !== 'object' + || typeof result.gateRevision !== 'number' + || typeof result.cleared !== 'boolean' + ) { + // NOT applied. The call returned, so something happened on the server — but a + // response this cannot read carries no revision, and reporting a write as + // applied on the strength of "it did not throw" is how a gate that was never + // set gets rendered as set. + return { + kind: 'unconfirmed', + detail: + `${GATE_WRITE_METHOD} answered with a shape carrying no gateRevision: ` + + `${JSON.stringify(value).slice(0, 200)}`, + }; + } + return { kind: 'applied', gateRevision: result.gateRevision, cleared: result.cleared }; +} + +/** + * A refusal from the fork, read off the RPC error. + * + * `RpcFailureError` (from `@cluesmith/t3-client`) carries the domain error, and + * `CodevGateWriteError` carries a `reason` literal. Read structurally rather than + * by importing the client: this module is driven by an injected writer in tests + * and must not acquire a transport dependency to classify a transport's error. + */ +function readRefusal(error: unknown): GateWriteOutcome { + const named = error as { name?: unknown; error?: unknown; message?: unknown } | null; + const domain = named?.error as { _tag?: unknown; reason?: unknown; detail?: unknown } | null; + if ( + named?.name === 'RpcFailureError' + && domain + && typeof domain === 'object' + && typeof domain.reason === 'string' + ) { + return { + kind: 'refused', + reason: domain.reason, + detail: typeof domain.detail === 'string' ? domain.detail : String(named.message ?? ''), + }; + } + return { + kind: 'unconfirmed', + detail: error instanceof Error ? error.message : String(error), + }; +} + +/** + * Write one projection to a thread. Sends no revision; reads the server's. + * + * `commandId` is generated per call and never reused: unlike `dispatchCommand` + * there is no journal here and no replay, because a gate write that did not land + * is superseded by the next publish cycle rather than recovered. Re-sending an + * old projection under an old id would resurrect a gate `status.yaml` has since + * cleared. + */ +export async function publishGate( + writer: GateWriter, + threadId: string, + projection: GateProjection, + options: { readonly commandId?: string; readonly now?: () => string } = {}, +): Promise { + const createdAt = (options.now ?? (() => new Date().toISOString()))(); + const commandId = options.commandId ?? cryptoRandomId(); + const payload = + projection.kind === 'clear' + ? { type: 'codev.gate.clear', commandId, threadId, createdAt } + : { type: 'codev.gate.set', commandId, threadId, gate: projection.gate, createdAt }; + try { + return readResult(await writer.call(GATE_WRITE_METHOD, payload)); + } catch (error) { + return readRefusal(error); + } +} + +function cryptoRandomId(): string { + return globalThis.crypto.randomUUID(); +} + +/** + * One thread's published gate state, so an unchanged `status.yaml` costs nothing. + * + * The memory is a WRITE SUPPRESSOR and never a source of truth. It answers "have + * I already sent exactly this?", and the only thing that may set it is a write the + * server confirmed. A refused or unconfirmed write leaves it untouched, so the + * next cycle sends again — which is the behaviour that makes an unconfirmed write + * safe to have. + * + * `forget` exists for reconnect: a new connection has published nothing, whatever + * this process remembers about the old one. + */ +export class GatePublisher { + readonly #lastPublished = new Map(); + + constructor( + private readonly writer: GateWriter, + private readonly onDropped?: (threadId: string, dropped: ReadonlyArray) => void, + ) {} + + /** Drop every memory of what has been published. Call on reconnect. */ + forget(): void { + this.#lastPublished.clear(); + } + + /** + * Publish one thread's gate, unless the identical projection already landed. + * + * Returns `null` when nothing was sent, which is distinct from every outcome — + * "no write was needed" and "the write succeeded" are different facts and a + * caller counting publishes must be able to tell them apart. + */ + async publish( + threadId: string, + status: PorchStatusProjection, + options: { readonly force?: boolean } = {}, + ): Promise { + const projection = projectGate(status); + const previous = this.#lastPublished.get(threadId); + if (!options.force && previous && sameProjection(previous, projection)) return null; + + if (projection.kind === 'set' && projection.dropped.length > 0) { + this.onDropped?.(threadId, projection.dropped); + } + const outcome = await publishGate(this.writer, threadId, projection); + // ONLY a confirmed write updates the memory. A refusal is settled for THIS + // write and says nothing about what the thread now carries — the server may + // have rejected a stale revision from another writer — and an unconfirmed one + // says nothing at all. Recording either would suppress the retry. + if (outcome.kind === 'applied') this.#lastPublished.set(threadId, projection); + return outcome; + } +} + +// ---------------------------------------------------------------- lifecycle + +/** + * The publish cycle, driven by the watch that already notices `status.yaml`. + * + * `status-reader.ts` is a reader with no cycle of its own, so the publisher + * needed a lifecycle naming rather than assuming one. `watchAgentState` is that + * lifecycle and it is reused rather than reimplemented: it already carries the + * debounce, the fingerprint over every artifact root, the 5s reconcile backstop + * for the macOS FSEvents arming window, and a distinct signal for a watch that + * failed to arm. A second watcher here would be a second set of all four, wrong + * in a different way. + * + * ## Why this lives with the connection + * + * It is started where the t3code socket is, and torn down with it. That is what + * makes "on reconnect it republishes current state" true without any code that + * knows about reconnects: a new connection builds a new `GatePublisher`, which + * remembers nothing, so the first cycle after it republishes everything. A + * publisher that outlived its socket would remember writes confirmed by a server + * it is no longer talking to. + * + * ## What it does NOT do + * + * It never reads the thread back. `status.yaml` is authoritative and the block is + * a projection of it; reconciling in the other direction would make the thread a + * second source of truth for a question the file already answers. + */ +export interface GateWatchOptions { + readonly workspaceRoot: string; + readonly writer: GateWriter; + /** Injected so a test drives the cycle without a filesystem. */ + readonly readStatuses?: (workspaceRoot: string) => ReadonlyArray; + readonly builderWorktrees?: (workspaceRoot: string) => ReadonlyArray; + readonly log?: (level: 'INFO' | 'WARN' | 'ERROR', message: string) => void; + readonly debounceMs?: number; + readonly reconcileMs?: number; +} + +export interface GateWatch { + close(): void; + /** Run one cycle now. Returns what was written, for a caller that wants to know. */ + publishNow(): Promise>; +} + +export function startGateWatch(options: GateWatchOptions): GateWatch { + const log = options.log ?? (() => {}); + const publisher = new GatePublisher(options.writer, (threadId, dropped) => { + // Reported at WARN because it is content a human was meant to read and will + // not. It is not an error: the gate published, which is the part that matters. + log( + 'WARN', + `Gate block for thread ${threadId} published without some content the fork would refuse: ` + + `${dropped.join('; ')}. status.yaml still carries all of it.`, + ); + }); + const readStatuses = options.readStatuses + ?? ((root: string) => readWorkspaceStatuses(root, [...(options.builderWorktrees?.(root) ?? [])])); + + const cycle = async () => { + const written: Array<{ threadId: string; outcome: GateWriteOutcome }> = []; + for (const result of readStatuses(options.workspaceRoot)) { + // A status that could not be read publishes NOTHING. Clearing the block on + // an unreadable file would spell "I could not read status.yaml" exactly like + // "no gate is pending" — on the one thread where a human may be waiting. + if (!result.ok) continue; + const threadId = result.status.threadId; + // No join key, no thread to publish onto. Not an error: `thread_id` is + // written only for thread-backed spawns. + if (!threadId) continue; + const outcome = await publisher.publish(threadId, result.status); + if (outcome === null) continue; + if (outcome.kind !== 'applied') { + log( + outcome.kind === 'refused' ? 'WARN' : 'ERROR', + `Gate write for thread ${threadId} was ${outcome.kind}: ` + + `${outcome.kind === 'refused' ? `${outcome.reason} — ${outcome.detail}` : outcome.detail}`, + ); + } + written.push({ threadId, outcome }); + } + return written; + }; + + /** + * One cycle at a time, SERIALIZED rather than skipped. + * + * Overlapping cycles race each other's revisions, so they must not run + * concurrently. The first version dropped a request while one was in flight and + * returned `[]` — which is "I did nothing" spelled exactly like "there was + * nothing to do", and it is worse than it sounds: the watcher fires on the same + * file change a caller is reacting to, so the dropped request was reliably the + * caller's. Found by a test whose explicit `publishNow` silently did nothing. + * + * Chaining instead means every request runs, in order, after whatever is ahead + * of it — and a cycle that finds nothing changed sends nothing, because the + * publisher already suppresses an identical projection. A rejected predecessor + * does not poison the chain: the next cycle re-reads `status.yaml`, which is + * the authoritative answer regardless of what happened before it. + */ + let queue: Promise = Promise.resolve(); + const runCycle = (): Promise> => { + const next = queue.then(cycle, cycle); + queue = next.then(() => undefined, () => undefined); + return next; + }; + + const subscription: StateSubscription = watchAgentState>({ + workspacePath: options.workspaceRoot, + snapshot: () => { + const statuses = readStatuses(options.workspaceRoot); + return { + payload: statuses, + artifactRoots: [ + options.workspaceRoot, + ...(options.builderWorktrees?.(options.workspaceRoot) ?? []), + ], + }; + }, + onEvent: (event) => { + if (event.type === 'STATE_STREAM_WATCH_FAILED') { + // Named, not swallowed. A watch that never armed still gets the 5s + // reconcile backstop, so gates keep publishing — but on a slower cadence + // than anyone reading the code would assume. + log('WARN', `Gate watch could not arm on ${options.workspaceRoot}: ${event.signal?.message ?? 'no reason given'}`); + return; + } + void runCycle().catch((error: unknown) => { + log('ERROR', `Gate publish cycle failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }, + ...(options.debounceMs === undefined ? {} : { debounceMs: options.debounceMs }), + ...(options.reconcileMs === undefined ? {} : { reconcileMs: options.reconcileMs }), + }); + + return { + close: () => subscription.close(), + publishNow: runCycle, + }; +} diff --git a/packages/codev/src/agent-farm/thread-backend.ts b/packages/codev/src/agent-farm/thread-backend.ts index 511f518e6..6f0b93023 100644 --- a/packages/codev/src/agent-farm/thread-backend.ts +++ b/packages/codev/src/agent-farm/thread-backend.ts @@ -12,11 +12,12 @@ * never be spelled the same way as a server that was never named. */ import { join, resolve } from 'node:path'; -import { statSync } from 'node:fs'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; import { DispatchJournal } from '@cluesmith/porch-driver/commands'; import { TurnTracker } from '@cluesmith/porch-driver/turn'; import { createPorchThreadEngine } from './porch-thread-engine.js'; import { createThreadSubscriptionPool, type ThreadSubscriber } from './thread-subscriptions.js'; +import { startGateWatch } from './servers/t3-gate-publisher.js'; import { canonicalWorkspaceKey, getThreadEngine, @@ -70,6 +71,21 @@ export interface ThreadBackendConfig { readonly workspaceRoot: string; readonly defaultHarness?: string; readonly defaultModel?: string; + /** + * Spec 250 phase 6. Absolute path to the gate-writer token the fork's server + * writes at start (`/codev/gate-writer.token`, mode 0600). + * + * Absent means gate publishing is off, and that is a configuration rather than + * a fault: a workspace can be thread-backed against an upstream t3code that has + * no gate block at all. Present-but-unreadable is a fault and is reported as + * one — "I was told where the credential is and could not read it" must not be + * spelled like "there is no credential". + * + * It is a PATH and not the token itself, so the credential never enters + * `.codev/config.json` and never reaches a config layer someone might commit. + * The server rotates it on every start; reading it late is the point. + */ + readonly gateWriterTokenPath?: string; } /** @@ -89,6 +105,7 @@ export function readThreadBackendConfig(workspaceRoot: string): ThreadBackendCon workspaceRoot, defaultHarness: process.env.CODEV_T3_HARNESS?.trim() || undefined, defaultModel: process.env.CODEV_T3_MODEL?.trim() || undefined, + gateWriterTokenPath: process.env.CODEV_T3_GATE_WRITER_TOKEN_PATH?.trim() || undefined, }; } @@ -121,9 +138,65 @@ export function readThreadBackendConfig(workspaceRoot: string): ThreadBackendCon workspaceRoot, defaultHarness: typeof threads.harness === 'string' ? threads.harness : undefined, defaultModel: typeof threads.model === 'string' ? threads.model : undefined, + // The env var wins, matching every other field in the env branch above. A + // path is not a secret, so unlike `bootstrapToken` it is safe in the + // committed config — what it points AT is the secret, and it stays on the + // server's disk at 0600. + gateWriterTokenPath: + process.env.CODEV_T3_GATE_WRITER_TOKEN_PATH?.trim() + || (typeof threads.gateWriterTokenPath === 'string' ? threads.gateWriterTokenPath.trim() : '') + || undefined, }; } +/** + * The gate-writer credential, read from where the fork's server wrote it. + * + * THREE ANSWERS, and the middle one is why this is not an inline `readFileSync`: + * + * not-configured no path was named. Gate publishing is off, deliberately — + * a workspace can be thread-backed against an upstream t3code + * that has no gate block at all. + * unreadable a path was named and could not be read, or held nothing. + * A FAULT: someone said where the credential is. Reporting it + * as "off" would leave every gate invisible with nothing said. + * token the credential. + * + * The token is never logged, never returned in an error message, and never put + * anywhere but the ticket request. `AuthError` already truncates a body that + * echoes one back. + */ +export type GateWriterCredential = + | { readonly kind: 'token'; readonly token: string } + | { readonly kind: 'not-configured' } + | { readonly kind: 'unreadable'; readonly detail: string }; + +export function readGateWriterToken(path: string | undefined): GateWriterCredential { + if (!path) return { kind: 'not-configured' }; + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + const errno = error as NodeJS.ErrnoException; + return { + kind: 'unreadable', + detail: + `${path}: ${errno.code ?? (error instanceof Error ? error.message : String(error))}` + + (errno.code === 'ENOENT' + ? ' — the fork writes this at server start, so an absent file usually means the server ' + + 'has not started, or is an upstream t3code with no gate support' + : ''), + }; + } + const token = raw.trim(); + // An empty file is not an empty credential, it is a half-written one — the + // fork writes to `.partial` and renames precisely so a reader never sees that, + // but a truncated token authenticates like a revoked one and would be reported + // as the server refusing us. + if (token === '') return { kind: 'unreadable', detail: `${path} is empty` }; + return { kind: 'token', token }; +} + /** * The WebSocket constructor to use, which is NOT always the global one. * @@ -205,6 +278,22 @@ async function connectDispatcher( config: ThreadBackendConfig, upgradeTimeoutMs: number, onClosed: () => void, + /** + * An access token to use INSTEAD of exchanging the bootstrap token. + * + * Spec 250 phase 6. The gate writer holds its own credential — `codev-agent`, + * scoped to `orchestration:read` and `codev:gate-write` and nothing else — and + * that credential is already an access token, written to disk by the fork's + * server at start. So it needs a connection and not an exchange. + * + * The "one socket rather than two" note below still holds and is not being + * walked back: the reason for one socket is that a SECOND EXCHANGE spends a + * one-time bootstrap token. This path performs no exchange, so it spends + * nothing. Sharing the engine's socket instead is the thing that cannot be + * done — that socket carries `orchestration:operate`, and putting gate writes + * on it is precisely what phase 4 gave the method its own scope to prevent. + */ + accessToken?: string, ): Promise<{ dispatcher: { call: (m: string, p: unknown) => Promise }; /** @@ -236,9 +325,11 @@ async function connectDispatcher( }> { const { T3Client } = await import('@cluesmith/t3-client/client'); const auth = await import('@cluesmith/t3-client/auth'); - const access = await auth.exchangeBootstrapToken(config.serverUrl, config.bootstrapToken, { - clientLabel: 'codev-afx', - }); + const access = accessToken !== undefined + ? { access_token: accessToken } + : await auth.exchangeBootstrapToken(config.serverUrl, config.bootstrapToken, { + clientLabel: 'codev-afx', + }); const ticket = await auth.issueWebSocketTicket(config.serverUrl, access.access_token); const WebSocketCtor = await webSocketCtor(); const socket = new WebSocketCtor(auth.webSocketUrl(config.serverUrl, ticket.ticket)); @@ -909,6 +1000,98 @@ async function initialiseThreadBackend( throw closedDuringInit(config.serverUrl, config.workspaceRoot); } installThreadSpawnFactory(key); + + /** + * Spec 250 phase 6. The gate publisher, on its OWN connection and credential. + * + * Started here because this is where the socket's life begins and ends, which + * is what makes "on reconnect it republishes current state" true with no code + * that knows about reconnects: a new connection builds a new `GatePublisher`, + * which remembers nothing, so its first cycle republishes everything. + * + * It does NOT reuse `dispatcher`. That socket carries `orchestration:operate`, + * and routing gate writes over it is exactly what phase 4 gave `codev.gateWrite` + * its own scope to prevent — the whole arrangement is that `codev-agent` holds + * the only `codev:gate-write` credential and nothing else does. + * + * NON-FATAL, in all three shapes. A workspace whose gates do not publish is a + * workspace where a human has to look at `status.yaml` instead of the sidebar; + * a workspace that cannot spawn is one where nothing runs at all. Trading the + * second for the first would be the wrong way round. Every failure is said out + * loud rather than inferred from a sidebar that shows no gates. + */ + // Declared before the connection so the socket's close handler can name the + // teardown it belongs to — the handler is passed in at connect time and the + // watch does not exist yet. + let stopThisWatch: (() => void) | undefined; + const credential = readGateWriterToken(config.gateWriterTokenPath); + if (credential.kind === 'unreadable') { + logger.warn( + `Gate publishing is off for ${config.workspaceRoot}: the gate-writer credential was named ` + + `and could not be read (${credential.detail}). Porch gates will not appear on threads; ` + + `status.yaml still carries all of them.`, + ); + } else if (credential.kind === 'token') { + try { + /** + * Stop whatever was watching before starting a new one. + * + * `ensureThreadBackendReady` re-initialises a workspace whose engine was + * evicted — which is exactly what a t3code restart causes — so this block + * runs again for the same key. `gateWatches.set` alone would drop the + * previous closer on the floor, leaking a live `fs.watch` AND a WebSocket + * per reconnect, in Tower, which runs for days. Raised in review; the + * teardown in `closeThreadBackend` did not cover it because a reconnect + * never goes through `closeThreadBackend`. + */ + gateWatches.get(key)?.(); + gateWatches.delete(key); + const gateConnection = await connectDispatcher( + config, + upgradeTimeoutMs, + // The gate socket carries no engine, so nothing else evicts it. Without + // this the entry outlives its own connection and `closeThreadBackend` + // later closes a socket that is already gone while the watch it points + // at has been publishing into a dead wire. + () => { + if (gateWatches.get(key) === stopThisWatch) gateWatches.delete(key); + stopThisWatch?.(); + }, + credential.token, + ); + const watch = startGateWatch({ + workspaceRoot: config.workspaceRoot, + writer: gateConnection.dispatcher, + builderWorktrees: (root) => builderArtifactRoots(root), + log: (level, message) => { + if (level === 'ERROR') logger.error(message); + else if (level === 'WARN') logger.warn(message); + else logger.info(message); + }, + }); + stopThisWatch = () => { + watch.close(); + gateConnection.close(); + }; + gateWatches.set(key, stopThisWatch); + // The first cycle NOW, not on the first file change. A gate that reached + // `pending` while this process was down would otherwise stay invisible until + // something touched `status.yaml` — which, for a gate waiting on a human, is + // exactly never. + void watch.publishNow().catch((error: unknown) => { + logger.warn( + `The first gate publish cycle for ${config.workspaceRoot} failed: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + }); + } catch (error) { + logger.warn( + `Gate publishing is off for ${config.workspaceRoot}: the gate-writer connection to ` + + `${config.serverUrl} could not be opened (${error instanceof Error ? error.message : String(error)}). ` + + `Spawning is unaffected.`, + ); + } + } // Remembered so a one-shot command can hang up when it is done — see // `closeThreadBackend`. Registered alongside the engine and dropped with it. // @@ -933,6 +1116,33 @@ async function initialiseThreadBackend( */ const hangUp = new Map void>(); +/** + * A per-workspace gate-watch closer. + * + * Separate from `hangUp` because it is a separate socket with a separate + * credential, and because it may be absent on a workspace whose engine connected + * fine — gate publishing is optional and its failures are non-fatal. + */ +const gateWatches = new Map void>(); + +/** + * The builder worktrees under a workspace, for the gate watch's artifact roots. + * + * A builder's `status.yaml` lives in ITS worktree, not in the workspace, so a + * watch that fingerprints only the workspace root would never notice a builder + * gate at all — which is every gate that matters here. + */ +function builderArtifactRoots(workspaceRoot: string): string[] { + try { + return readdirSync(join(workspaceRoot, '.builders'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(workspaceRoot, '.builders', entry.name)); + } catch { + // No `.builders` yet is the ordinary state of a fresh workspace. + return []; + } +} + /** * Hang up this workspace's t3code connection and drop what was registered on it. * @@ -960,6 +1170,16 @@ export function closeThreadBackend(workspaceRoot: string): void { const key = canonicalWorkspaceKey(workspaceRoot); const close = hangUp.get(key); hangUp.delete(key); + // BEFORE the early return below, and unconditionally. + // + // The gate watch is a separate socket AND a file watcher, and it can exist on a + // workspace whose engine never registered — gate publishing is optional and its + // failures are non-fatal, so `hangUp` being empty says nothing about it. Behind + // the `if (!close) return` it would have leaked a live `fs.watch` and a live + // WebSocket on exactly the path a one-shot command takes to exit. + const stopGates = gateWatches.get(key); + gateWatches.delete(key); + stopGates?.(); // Dropped whether or not there was a socket to close: leaving an engine registered on a // connection nobody holds is the dead-engine state this module works to avoid. setThreadEngine(undefined, key); diff --git a/packages/codev/src/lib/config.ts b/packages/codev/src/lib/config.ts index bc561167f..0d4a6ba91 100644 --- a/packages/codev/src/lib/config.ts +++ b/packages/codev/src/lib/config.ts @@ -44,6 +44,15 @@ export interface CodevConfig { bootstrapToken?: string; harness?: string; model?: string; + /** + * Spec 250. Absolute path to the gate-writer token the fork's t3code server + * writes at start (`/codev/gate-writer.token`). + * + * A path, not the credential — safe in the committed layer, unlike + * `bootstrapToken`. Absent turns gate publishing off; naming a path that + * cannot be read is a fault and is reported as one. + */ + gateWriterTokenPath?: string; }; shell?: { architect?: string | string[]; diff --git a/packages/porch-driver/src/thread.ts b/packages/porch-driver/src/thread.ts index f4a19ea1b..bc270665a 100644 --- a/packages/porch-driver/src/thread.ts +++ b/packages/porch-driver/src/thread.ts @@ -148,6 +148,101 @@ export interface CreateThreadOptions { readonly guardFiles?: ReadonlyArray; /** Cap on retained events. Default 5,000. */ readonly retainEvents?: number; + /** + * Spec 250. Where this thread sits in Workspace > Architect > Builders. + * + * Optional on the wire, so a caller that does not know — and any upstream + * client — dispatches `thread.create` exactly as before. Omitted rather than + * sent as null when absent: the fork reads an absent key and an explicit null + * the same way, and sending a field we were not given is a claim we cannot + * make. + */ + readonly role?: ThreadRole; + /** + * The architect thread this builder belongs to. + * + * Required whenever `role` is `builder` — see {@link HierarchyRefusedError}. + * Must be absent or null for an architect: the fork refuses + * `parent-on-non-builder`, and refusing it here means the caller learns which + * of its two fields was wrong without a round trip. + */ + readonly parentThreadId?: string | null; +} + +/** Spec 250. The two roles the fork's thread record can carry. */ +export type ThreadRole = 'architect' | 'builder'; + +/** + * The six reasons the fork refuses a hierarchy edge. + * + * Copied deliberately from `CodevHierarchyInvalidReason` in the fork's + * `apps/server/src/orchestration/Errors.ts` rather than derived: this package + * does not import the fork, and the vendored contract does not carry the error + * union (`generate.mjs` emits RPC payloads, not error schemas). A drift test + * asserts the two lists agree against the fork checkout when it is present, so + * the copy is checked rather than trusted. + * + * Two of the six are decidable without the server, and are refused here: + * + * builder-without-parent `role: "builder"` and no parent. A builder is owned + * by definition. + * parent-on-non-builder a parent on an architect, or on no role at all. + * + * The other four need the projection — does the parent exist, is it in this + * project, is it an architect, is it this very thread — and only the server can + * answer those. They are named here so a caller reading a refusal off the wire + * branches on the same vocabulary it would get locally. + */ +export const HIERARCHY_REFUSAL_REASONS = [ + 'parent-not-found', + 'parent-in-other-project', + 'parent-is-self', + 'parent-not-architect', + 'builder-without-parent', + 'parent-on-non-builder', +] as const; +export type HierarchyRefusalReason = (typeof HIERARCHY_REFUSAL_REASONS)[number]; + +/** + * A hierarchy edge this client refused to send. + * + * Carries the SAME `reason` vocabulary the server would have answered with, so a + * caller branches on one set of strings whichever side refused. It is a distinct + * error class rather than a generic one because "you did not give me a parent" + * and "the server rejected the parent you gave me" lead to different fixes, and + * the first should never require a round trip to discover. + */ +export class HierarchyRefusedError extends Error { + constructor( + readonly reason: HierarchyRefusalReason, + readonly threadId: string, + readonly parentThreadId: string | null, + detail: string, + ) { + super(`Codev hierarchy invalid (thread.create, ${reason}): ${detail}`); + this.name = 'HierarchyRefusedError'; + } +} + +/** + * The two hierarchy rules that do not need the server, applied before dispatch. + * + * Exported because `attach` cannot check them (an attached thread's role lives on + * the server, not in the options) and a caller assembling a create may want to + * validate before it has a worktree. Returns the reason rather than throwing, so + * the caller decides whether it is an error or a prompt. + */ +export function localHierarchyRefusal(options: { + readonly role?: ThreadRole; + readonly parentThreadId?: string | null; +}): HierarchyRefusalReason | null { + const parent = options.parentThreadId ?? null; + if (options.role === 'builder') { + return parent === null ? 'builder-without-parent' : null; + } + // No role at all counts. The fork's `parent-on-non-builder` covers `role: + // "architect"` AND an absent role, because a parent means nothing without one. + return parent === null ? null : 'parent-on-non-builder'; } /** @@ -273,6 +368,29 @@ export class DriverThread { } const threadId = options.threadId ?? newCommandId(); + + /** + * BEFORE the worktree is laid down, not after. + * + * A refused hierarchy leaves nothing behind: no thread on the server, and no + * guard files, role file or settings written into a directory on the strength + * of a create that was never going to happen. The model check above is + * ordered the same way and for the same reason. + */ + const refusal = localHierarchyRefusal(options); + if (refusal !== null) { + throw new HierarchyRefusedError( + refusal, + threadId, + options.parentThreadId ?? null, + refusal === 'builder-without-parent' + ? `a builder thread must name the architect thread that owns it; role was "builder" and ` + + `parentThreadId was ${options.parentThreadId === undefined ? 'omitted' : 'null'}` + : `parentThreadId was given as ${JSON.stringify(options.parentThreadId)} with role ` + + `${options.role === undefined ? 'omitted' : JSON.stringify(options.role)}; only a builder has a parent`, + ); + } + const setup = planWorktreeSetup(mapping.driverKind, { worktreePath: options.worktreePath, guardFiles: options.guardFiles, @@ -304,6 +422,16 @@ export class DriverThread { // whose worktree is the workspace root" could not be true in production. branch: options.branch === '' ? null : options.branch, worktreePath: options.worktreePath, + // Spec 250. OMITTED when not given, rather than sent as null. + // + // The fork reads an absent key and an explicit null identically, so the two + // are equivalent to the server — but they are not equivalent to a reader of + // the journal, and this payload is journalled before it is sent. `role: + // null` in a recorded intent reads as "the caller decided this thread has + // no role"; an absent key reads as "the caller was not told", which is what + // actually happened. + ...(options.role === undefined ? {} : { role: options.role }), + ...(options.parentThreadId === undefined ? {} : { parentThreadId: options.parentThreadId }), createdAt: new Date().toISOString(), }); diff --git a/packages/t3-client/live/integration.mjs b/packages/t3-client/live/integration.mjs index 202584233..dfb362464 100644 --- a/packages/t3-client/live/integration.mjs +++ b/packages/t3-client/live/integration.mjs @@ -67,16 +67,21 @@ const { ResumingSubscription } = await import(join(distDir, 'subscription.js')); const clients = []; /** - * The checkout the server operates on. + * The checkout the server operates on: the UPSTREAM identity (spec 250). + * + * These are the spec 146 / #241 live tests. Their meaning is unchanged by the fork existing, + * and they must keep measuring the tree the recorded evidence describes, so this stays + * `T3CODE_ROOT` and never falls back to `T3CODE_FORK_ROOT`. * * Required rather than defaulted (#214). The default was one machine's absolute path, so * anyone else running this got a failure somewhere inside the server rather than a sentence * naming the missing input. `live/` is not in the package's `files`, so this never reached a * tarball — it was committed, which is a smaller problem and still not one worth keeping. + * Keeping it required also means the fork's path cannot arrive here by accident. */ const T3CODE_ROOT = process.env.T3CODE_ROOT; if (!T3CODE_ROOT) { - console.error('T3CODE_ROOT is not set. Point it at your t3code checkout and re-run.'); + console.error('T3CODE_ROOT is not set. Point it at your upstream t3code checkout and re-run.'); process.exit(2); } @@ -194,7 +199,7 @@ try { run('stop'); } catch { /* nothing running */ } run('acquire'); -run('verify'); +run('verify-upstream'); // upstream identity (spec 250): these tests never read the fork run('start'); let project; diff --git a/packages/t3-client/live/spec-250-hierarchy.mjs b/packages/t3-client/live/spec-250-hierarchy.mjs new file mode 100644 index 000000000..62725602b --- /dev/null +++ b/packages/t3-client/live/spec-250-hierarchy.mjs @@ -0,0 +1,362 @@ +/** + * Spec 250, Phase 6 — the last hop, against a live FORK server. + * + * ## Why this file exists at all + * + * Phase 3 gave the fork six `CodevHierarchyInvalidReason` discriminants and + * tested them at the decider. Phase 3's review then found `OrchestrationEngine` + * rewriting every one of them into "Failed to generate an event identifier" — + * green in every test beneath the layer that broke them. Phase 4 found the same + * function deleting gate refusals, in the same way, one phase later. + * + * Both were caught below the boundary a real client crosses. **This runs above + * it.** A discriminant that does not survive serialization does not exist, and + * the only way to know is to dispatch an illegal edge over a socket and read what + * comes back. + * + * ## It needs the FORK server, and that is not the harness's usual one + * + * `t3-server.mjs start` runs the PUBLISHED `t3@` CLI against the + * upstream checkout, which is what every spec 146 measurement is about. That + * server has no `codev.*` anything: `parentThreadId` is not in its contract, so an + * illegal edge is not illegal there, it is an unknown field the decoder strips. + * Testing against it would produce a passing run that proves nothing. + * + * So this uses `start-fork`, which runs the fork's `apps/server/src/bin.ts` + * directly, on its own port and its own runtime directory. It never touches the + * upstream server or its data. + * + * Usage: + * export T3_NODE=/absolute/path/to/node + * export T3CODE_FORK_ROOT=/path/to/fork T3_HARNESS_PORT= T3_HARNESS_DIR= + * node packages/t3-client/live/spec-250-hierarchy.mjs --out codev/research/250-hierarchy-wire-evidence.json + * + * Exit 0 when every claim held, 1 when one did not, 3 when it could not tell. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const OK = 0; +const MISMATCH = 1; +const UNDETERMINED = 3; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..', '..'); +const harness = join(repoRoot, 'tools', 't3-server', 't3-server.mjs'); +const distDir = join(here, '..', 'dist'); + +const die = (code, message) => { + console.error(`[spec-250-hierarchy] ${message}`); + process.exit(code); +}; + +if (!existsSync(join(distDir, 'client.js'))) { + die( + UNDETERMINED, + `COULD_NOT_TELL: ${distDir} has no built client. Run the workspace build first — a missing ` + + `build is not a failing wire test.`, + ); +} + +const FORK_ROOT = process.env.T3CODE_FORK_ROOT; +if (!FORK_ROOT || !existsSync(FORK_ROOT)) { + die( + UNDETERMINED, + `COULD_NOT_TELL: T3CODE_FORK_ROOT is ${FORK_ROOT ? `${FORK_ROOT}, which does not exist` : 'unset'}. ` + + `This test is ABOUT the fork's server; there is nothing to fall back to.`, + ); +} + +const { T3Client } = await import(join(distDir, 'client.js')); +const auth = await import(join(distDir, 'auth.js')); + +const port = Number(process.env.T3_HARNESS_PORT ?? 3799); +const base = `http://127.0.0.1:${port}`; +const run = (...args) => execFileSync('node', [harness, ...args], { encoding: 'utf8' }); +const id = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +const now = () => new Date().toISOString(); + +const outIdx = process.argv.indexOf('--out'); +const outPath = outIdx >= 0 ? resolve(repoRoot, process.argv[outIdx + 1]) : null; + +const claims = []; +const record = (name, passed, detail) => claims.push({ name, passed, detail }); + +let ACCESS = null; +async function accessToken() { + if (ACCESS) return ACCESS; + const readyOut = run('ready'); + const { token } = JSON.parse(readyOut.slice(readyOut.indexOf('{'))); + ACCESS = await auth.exchangeBootstrapToken(base, token, { clientLabel: 'codev-spec250-live' }); + return ACCESS; +} + +const clients = []; +async function connect() { + const access = await accessToken(); + const ticket = await auth.issueWebSocketTicket(base, access.access_token); + const socket = new WebSocket(auth.webSocketUrl(base, ticket.ticket)); + await new Promise((res, rej) => { + socket.addEventListener('open', res, { once: true }); + socket.addEventListener('error', () => rej(new Error('socket error')), { once: true }); + }); + const client = new T3Client( + { + send: (d) => socket.send(d), + close: () => socket.close(), + addEventListener: (t, l) => socket.addEventListener(t, l), + get readyState() { return socket.readyState; }, + }, + { requestTimeoutMs: 45_000 }, + ); + clients.push({ client, socket }); + return client; +} + +/** + * Dispatch a `thread.create` and return what came back off the WIRE. + * + * The three shapes are kept apart on purpose: + * + * accepted the server applied it. For an illegal edge that is a FAILURE of + * this test, not an error of the harness. + * refused an `RpcFailureError` carrying a domain error. `tag` and `reason` + * are read through the client's own accessors, which is what a real + * caller would use. + * opaque it failed and carried no readable reason — the exact condition + * this file exists to detect. Never spelled like `refused`. + */ +async function createThread(client, fields) { + try { + await client.call('orchestration.dispatchCommand', { + type: 'thread.create', + commandId: id(), + modelSelection: { instanceId: 'codex', model: 'gpt-5.6-luna' }, + runtimeMode: 'full-access', + interactionMode: 'default', + branch: null, + worktreePath: FORK_ROOT, + createdAt: now(), + ...fields, + }); + return { kind: 'accepted' }; + } catch (error) { + if (error?.name !== 'RpcFailureError') { + return { kind: 'opaque', detail: `${error?.name ?? 'unknown'}: ${String(error?.message ?? error).slice(0, 200)}` }; + } + const domain = error.error; + /** + * `refusal` FIRST, and this is the whole finding. + * + * The first draft of this file read `domain.reason`, on the assumption that + * the `CodevHierarchyInvalidError` itself reaches the client. It does not — + * `ws.ts` wraps every dispatch failure in `OrchestrationDispatchCommandError` + * — so the reason was undefined and the run reported `opaque` for all four + * cases. That was a TRUE reading of the server as it stood: the discriminant + * existed only inside the message. + * + * The fork now lifts it onto `refusal`, so that is where a client reads it. + * `domain.reason` is still checked as a fallback because a future path could + * surface the tagged error directly, and a client that knew only one shape + * would report a readable refusal as opaque. + */ + const refusal = domain && typeof domain === 'object' ? domain.refusal : undefined; + const reason = (refusal && typeof refusal === 'object' ? refusal.reason : undefined) + ?? (domain && typeof domain === 'object' ? domain.reason : undefined); + if (typeof reason !== 'string') { + return { + kind: 'opaque', + detail: + `the refusal reached the client with tag ${JSON.stringify(error.tag)} and no readable reason: ` + + `${JSON.stringify(domain).slice(0, 300)}`, + }; + } + return { + kind: 'refused', + // The REFUSING error's tag, not the envelope's. `error.tag` is always + // `OrchestrationDispatchCommandError` now, which says nothing about what + // refused; `refusal.tag` is the error that made the decision. + tag: (refusal && typeof refusal === 'object' ? refusal.tag : undefined) ?? error.tag, + reason, + parentThreadId: domain?.parentThreadId ?? null, + }; + } +} + +// ---------------------------------------------------------------- setup + +try { run('stop'); } catch { /* nothing running */ } +run('start-fork'); +run('verify-fork'); + +let exitCode = OK; +try { + const client = await connect(); + + const projectA = id(); + const projectB = id(); + for (const [projectId, title] of [[projectA, 'spec 250 wire A'], [projectB, 'spec 250 wire B']]) { + await client.call('orchestration.dispatchCommand', { + type: 'project.create', + commandId: id(), + projectId, + title, + workspaceRoot: projectId === projectA ? FORK_ROOT : join(FORK_ROOT, 'apps'), + defaultModelSelection: { instanceId: 'codex', model: 'gpt-5.6-luna' }, + createdAt: now(), + }); + } + + const architect = id(); + const builder = id(); + const architectElsewhere = id(); + + // ---------------------------------------------------------- the legal edges + + const madeArchitect = await createThread(client, { + threadId: architect, projectId: projectA, title: 'architect', role: 'architect', + }); + record('an architect thread is accepted with a role and no parent', madeArchitect.kind === 'accepted', JSON.stringify(madeArchitect)); + + const madeBuilder = await createThread(client, { + threadId: builder, projectId: projectA, title: 'builder', role: 'builder', parentThreadId: architect, + }); + record('a builder thread is accepted with its architect as parent', madeBuilder.kind === 'accepted', JSON.stringify(madeBuilder)); + + await createThread(client, { + threadId: architectElsewhere, projectId: projectB, title: 'architect elsewhere', role: 'architect', + }); + + // ------------------------------------------------ the discriminants, on the wire + + /** + * The four the server must decide, each with a DIFFERENT answer. + * + * The criterion is not "it refused" — a single opaque failure would satisfy + * that. It is that a client can tell "no such parent" from "wrong parent role", + * which needs the reasons to arrive intact AND to differ. + */ + const cases = [ + { + name: 'parent-not-found', + fields: { threadId: id(), projectId: projectA, title: 'orphan', role: 'builder', parentThreadId: id() }, + }, + { + name: 'parent-not-architect', + fields: { threadId: id(), projectId: projectA, title: 'nested builder', role: 'builder', parentThreadId: builder }, + }, + { + name: 'parent-in-other-project', + fields: { threadId: id(), projectId: projectA, title: 'cross project', role: 'builder', parentThreadId: architectElsewhere }, + }, + ]; + + const observed = {}; + for (const testCase of cases) { + const outcome = await createThread(client, testCase.fields); + observed[testCase.name] = outcome; + record( + `${testCase.name} arrives as a readable reason`, + outcome.kind === 'refused' && outcome.reason === testCase.name, + JSON.stringify(outcome), + ); + } + + // `parent-is-self` needs the thread to name its own id, which no other case can + // stand in for. + const selfId = id(); + const self = await createThread(client, { + threadId: selfId, projectId: projectA, title: 'self parent', role: 'builder', parentThreadId: selfId, + }); + observed['parent-is-self'] = self; + record('parent-is-self arrives as a readable reason', self.kind === 'refused' && self.reason === 'parent-is-self', JSON.stringify(self)); + + /** + * THE CRITERION, stated as its own claim. + * + * Four refusals with four different reasons. If the engine collapsed them — as + * it did in phase 3 and again in phase 4 — every one of them would still be a + * refusal, and this is the assertion that would fail. + */ + const reasons = Object.values(observed) + .filter((o) => o.kind === 'refused') + .map((o) => o.reason); + record( + 'the four reasons are distinguishable from one another', + new Set(reasons).size === 4, + `reasons: ${JSON.stringify(reasons)}`, + ); + + record( + 'every refusal carried the CodevHierarchyInvalidError tag', + Object.values(observed).every((o) => o.kind === 'refused' && o.tag === 'CodevHierarchyInvalidError'), + JSON.stringify(Object.fromEntries(Object.entries(observed).map(([k, v]) => [k, v.tag ?? v.kind]))), + ); + + /** + * The sources this run depended on, hashed. + * + * NOT mtimes and NOT commit times. Review found the mtime form flakes on a + * fresh clone — git writes files in whatever order it likes, so the evidence + * can look older than a source it is current with. Commit time fixes that and + * breaks differently: a file written, run, and THEN committed always looks + * newer than the run it produced, which is the ordinary way this file is + * edited. + * + * A content hash is neither. It answers the question the guard actually means — + * "is this evidence about the code that is here now?" — and it is the same + * mechanism `generated/source-hash.json` already uses for the contract. + * + * The client's read path is in the list because the whole claim is that a + * CLIENT can read the discriminant: `envelope.ts` is where `RpcFailureError` + * decides what `error` and `tag` mean. + */ + const sourceHashes = {}; + for (const relative of [ + 'packages/t3-client/live/spec-250-hierarchy.mjs', + 'packages/t3-client/src/envelope.ts', + 'packages/t3-client/src/client.ts', + 'tools/t3-server/t3-server.mjs', + ]) { + sourceHashes[relative] = createHash('sha256') + .update(readFileSync(join(repoRoot, relative))) + .digest('hex'); + } + + const evidence = { + _comment: + 'Spec 250 phase 6. Generated by packages/t3-client/live/spec-250-hierarchy.mjs against a live ' + + 'FORK server started with `t3-server.mjs start-fork`. Do not hand-edit.', + recordedAt: now(), + forkRoot: FORK_ROOT, + forkCommit: execFileSync('git', ['-C', FORK_ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + port, + algorithm: 'sha256', + sourceHashes, + observed, + claims, + passed: claims.every((c) => c.passed), + }; + const rendered = `${JSON.stringify(evidence, null, 2)}\n`; + if (outPath) writeFileSync(outPath, rendered); + else console.log(rendered); + + for (const claim of claims) { + console.error(`[spec-250-hierarchy] ${claim.passed ? 'ok ' : 'FAIL'} ${claim.name}`); + if (!claim.passed) console.error(` ${claim.detail}`); + } + exitCode = evidence.passed ? OK : MISMATCH; +} catch (error) { + console.error(`[spec-250-hierarchy] COULD_NOT_TELL: ${error instanceof Error ? error.stack : String(error)}`); + exitCode = UNDETERMINED; +} finally { + for (const { socket } of clients) { + try { socket.close(); } catch { /* already closing */ } + } + try { run('stop'); } catch { /* already stopped */ } +} +process.exit(exitCode); diff --git a/packages/types/src/t3/generated/ATTRIBUTION.md b/packages/types/src/t3/generated/ATTRIBUTION.md index befdd1832..369174b51 100644 --- a/packages/types/src/t3/generated/ATTRIBUTION.md +++ b/packages/types/src/t3/generated/ATTRIBUTION.md @@ -2,16 +2,23 @@ The files in this directory are **generated from t3code**, which is MIT licensed. -- Source: https://github.com/pingdotgg/t3code -- Commit: `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6` (2026-08-25) +They are generated from a **private modified copy** of t3code, not from the upstream +repository, so both are named. `2f64a1b0ee2b35cd858a8b601b4d425216e73ae5` exists only in the fork; looking for it in +https://github.com/pingdotgg/t3code would not find it, and an attribution that named upstream alone would be +pointing at a commit that is not the source of these files. + +- Generated from: https://github.com/pseudoseed/t3code.git — commit `2f64a1b0ee2b35cd858a8b601b4d425216e73ae5` (2026-08-31), branch `codev` +- Which branched from: https://github.com/pingdotgg/t3code — commit `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6` (2026-08-25) - Generated by: `tools/t3-codegen/generate.mjs` +- Modifications: see `tools/t3-fork/FORK.md` and the exported patches in `tools/t3-fork/patches/` `@cluesmith/codev-types` is published under Apache-2.0 and ships `files: ["src", "dist"]`, so these derived artifacts leave this repository inside a distributed package. MIT requires its notice to travel with the distribution, which is why this file sits beside them rather than in a place a packaging step might drop. -The notice below is reproduced verbatim from `LICENSE` at the pinned commit. +The notice below is reproduced verbatim from `LICENSE` at the pinned commit. The fork carries +it unmodified from upstream. ``` MIT License diff --git a/packages/types/src/t3/generated/LOSSY.md b/packages/types/src/t3/generated/LOSSY.md index ca9cbd765..17ba89292 100644 --- a/packages/types/src/t3/generated/LOSSY.md +++ b/packages/types/src/t3/generated/LOSSY.md @@ -56,7 +56,7 @@ Found by scanning the generated schemas rather than the source symbols. The sour `ModelSelectionSource` is one, and its fields land here. A `{}` accepts any value whatsoever, which is a stronger loss than a bare typed schema. -- `OrchestrationEvent/anyOf/28/payload/activity/payload` → `{}` — no constraints at all: accepts any value +- `OrchestrationEvent/anyOf/30/payload/activity/payload` → `{}` — no constraints at all: accepts any value - `$defs/dispatchCommandInput__Objects_/provider/anyOf/0` → `{}` — no constraints at all: accepts any value - `$defs/dispatchCommandInput__Objects_/instanceId/anyOf/0` → `{}` — no constraints at all: accepts any value - `$defs/dispatchCommandInput__Objects_/model` → `{}` — no constraints at all: accepts any value @@ -65,7 +65,7 @@ which is a stronger loss than a bare typed schema. - `$defs/subscribeThreadOutput__Objects_/instanceId/anyOf/0` → `{}` — no constraints at all: accepts any value - `$defs/subscribeThreadOutput__Objects_/model` → `{}` — no constraints at all: accepts any value - `$defs/subscribeThreadOutput__Objects_/options/anyOf/0` → `{}` — no constraints at all: accepts any value -- `$defs/subscribeThreadOutput__Objects_4/payload` → `{}` — no constraints at all: accepts any value +- `$defs/subscribeThreadOutput__Objects_5/payload` → `{}` — no constraints at all: accepts any value - `$defs/OrchestrationEvent__Objects_2/provider/anyOf/0` → `{}` — no constraints at all: accepts any value - `$defs/OrchestrationEvent__Objects_2/instanceId/anyOf/0` → `{}` — no constraints at all: accepts any value - `$defs/OrchestrationEvent__Objects_2/model` → `{}` — no constraints at all: accepts any value diff --git a/packages/types/src/t3/generated/UNREPRESENTED.md b/packages/types/src/t3/generated/UNREPRESENTED.md index ef6558bac..2f09c88d8 100644 --- a/packages/types/src/t3/generated/UNREPRESENTED.md +++ b/packages/types/src/t3/generated/UNREPRESENTED.md @@ -4,9 +4,9 @@ Generated. Schemas the emitter could not represent at all. An entry here is more than one in LOSSY.md: there is no JSON Schema for it, so `shape-check.ts` cannot check it in any form. -**Scope:** this covers the 17 schemas Codev actually consumes plus every +**Scope:** this covers the 20 schemas Codev actually consumes plus every schema reached by the loss scan — not literally every export in the closure. A schema nothing here imports is never emitted, so it can be neither represented nor unrepresented. Claiming "every schema in the closure was representable" would assert something this tool never tested. -_None of the 17 consumed schemas failed to emit._ +_None of the 20 consumed schemas failed to emit._ diff --git a/packages/types/src/t3/generated/methods.json b/packages/types/src/t3/generated/methods.json index fc7382d28..fc108dcf1 100644 --- a/packages/types/src/t3/generated/methods.json +++ b/packages/types/src/t3/generated/methods.json @@ -19,6 +19,11 @@ "output": "searchThreadsOutput", "stream": false }, + "codev.gateWrite": { + "input": "CodevGateWriteInput", + "output": "CodevGateWriteResult", + "stream": false + }, "vcs.createWorktree": { "input": "VcsCreateWorktreeInput", "output": "VcsCreateWorktreeResult", diff --git a/packages/types/src/t3/generated/schema.json b/packages/types/src/t3/generated/schema.json index 06924bcb6..abf4061b3 100644 --- a/packages/types/src/t3/generated/schema.json +++ b/packages/types/src/t3/generated/schema.json @@ -114,6 +114,112 @@ "additionalProperties": false }, "subscribeThreadOutput__Objects_3": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "subscribeThreadOutput__Objects_4": { "type": "object", "properties": { "requestId": { @@ -172,7 +278,7 @@ } ] }, - "subscribeThreadOutput__Objects_4": { + "subscribeThreadOutput__Objects_5": { "type": "object", "properties": { "id": { @@ -234,7 +340,7 @@ ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_5": { + "subscribeThreadOutput__Objects_6": { "type": "object", "properties": { "path": { @@ -268,7 +374,7 @@ ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_6": { + "subscribeThreadOutput__Objects_7": { "type": "object", "properties": { "threadId": { @@ -356,7 +462,7 @@ ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_7": { + "subscribeThreadOutput__Objects_8": { "type": "object", "properties": { "providerTurnId": { @@ -450,7 +556,7 @@ }, "additionalProperties": false }, - "subscribeThreadOutput__Objects_8": { + "subscribeThreadOutput__Objects_9": { "type": "object", "properties": { "canonicalKey": { @@ -501,7 +607,7 @@ ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_9": { + "subscribeThreadOutput__Objects_10": { "type": "object", "properties": { "id": { @@ -1223,6 +1329,44 @@ } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" } @@ -2493,6 +2637,83 @@ } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "codevGate": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "gateRevision": { + "anyOf": [ + { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" }, @@ -2627,7 +2848,7 @@ { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + "$ref": "#/$defs/subscribeThreadOutput__Objects_4" }, { "type": "null" @@ -2795,7 +3016,7 @@ "activities": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_4" + "$ref": "#/$defs/subscribeThreadOutput__Objects_5" } }, "checkpoints": { @@ -2828,7 +3049,7 @@ "files": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_5" + "$ref": "#/$defs/subscribeThreadOutput__Objects_6" } }, "assistantMessageId": { @@ -2860,7 +3081,7 @@ "session": { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_6" + "$ref": "#/$defs/subscribeThreadOutput__Objects_7" }, { "type": "null" @@ -3024,7 +3245,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3049,7 +3270,7 @@ { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + "$ref": "#/$defs/subscribeThreadOutput__Objects_9" }, { "type": "null" @@ -3091,7 +3312,7 @@ "scripts": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_9" + "$ref": "#/$defs/subscribeThreadOutput__Objects_10" } }, "createdAt": { @@ -3193,7 +3414,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3232,7 +3453,7 @@ { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + "$ref": "#/$defs/subscribeThreadOutput__Objects_9" }, { "type": "null" @@ -3304,7 +3525,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_9" + "$ref": "#/$defs/subscribeThreadOutput__Objects_10" } }, { @@ -3403,7 +3624,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3508,7 +3729,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3581,6 +3802,44 @@ } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" }, @@ -3681,12 +3940,12 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", "enum": [ - "thread.deleted" + "codev.gate-set" ] }, "payload": { @@ -3695,13 +3954,26 @@ "threadId": { "type": "string" }, - "deletedAt": { + "gate": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { "type": "string" } }, "required": [ "threadId", - "deletedAt" + "gate", + "gateRevision", + "updatedAt" ], "additionalProperties": false } @@ -3786,12 +4058,231 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", "enum": [ - "thread.archived" + "codev.gate-cleared" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "gateRevision", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + }, + "type": { + "type": "string", + "enum": [ + "thread.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "deletedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + }, + "type": { + "type": "string", + "enum": [ + "thread.archived" ] }, "payload": { @@ -3895,7 +4386,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4000,7 +4491,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4109,7 +4600,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4222,7 +4713,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4335,7 +4826,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4448,7 +4939,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4567,7 +5058,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4672,7 +5163,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4781,7 +5272,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4833,7 +5324,7 @@ { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + "$ref": "#/$defs/subscribeThreadOutput__Objects_4" }, { "type": "null" @@ -4997,7 +5488,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5112,7 +5603,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5231,7 +5722,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5385,7 +5876,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5554,7 +6045,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5669,7 +6160,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5789,7 +6280,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5902,7 +6393,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6016,7 +6507,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6126,7 +6617,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6231,7 +6722,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6246,7 +6737,7 @@ "type": "string" }, "session": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_6" + "$ref": "#/$defs/subscribeThreadOutput__Objects_7" } }, "required": [ @@ -6336,7 +6827,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6507,7 +6998,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6546,7 +7037,7 @@ "files": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_5" + "$ref": "#/$defs/subscribeThreadOutput__Objects_6" } }, "assistantMessageId": { @@ -6656,7 +7147,7 @@ ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6671,7 +7162,7 @@ "type": "string" }, "activity": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_4" + "$ref": "#/$defs/subscribeThreadOutput__Objects_5" } }, "required": [ @@ -6790,42 +7281,262 @@ ], "additionalProperties": false }, - "VcsCreateWorktreeInput": { - "type": "object", - "properties": { - "cwd": { - "type": "string" - }, - "refName": { - "type": "string" - }, - "newRefName": { - "anyOf": [ - { - "type": "string" + "CodevGateWriteInput": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "codev.gate.set" + ] }, - { - "type": "null" - } - ] - }, - "baseRefName": { - "anyOf": [ - { + "commandId": { "type": "string" }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { + "threadId": { "type": "string" }, - { - "type": "null" + "gate": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "revision": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "gate", + "createdAt" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "codev.gate.clear" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "revision": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "createdAt" + ], + "additionalProperties": false + } + ] + }, + "CodevGateWriteResult": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "cleared": { + "type": "boolean" + } + }, + "required": [ + "threadId", + "gateRevision", + "cleared" + ], + "additionalProperties": false + }, + "VcsCreateWorktreeInput": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "refName": { + "type": "string" + }, + "newRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } ] } @@ -7459,32 +8170,352 @@ } ] }, - "defaultModelSelection": { + "defaultModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationEvent__Objects_2" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "faviconPath": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "scripts": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/OrchestrationEvent__Objects_3" + } + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/OrchestrationEvent__Objects_" + }, + "type": { + "type": "string", + "enum": [ + "project.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "deletedAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/OrchestrationEvent__Objects_" + }, + "type": { + "type": "string", + "enum": [ + "thread.created" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/OrchestrationEvent__Objects_2" + }, + "runtimeMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + }, + "interactionMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + { + "type": "null" + } + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { "anyOf": [ { - "anyOf": [ - { - "$ref": "#/$defs/OrchestrationEvent__Objects_2" - }, - { - "type": "null" - } - ] + "type": "string" }, { "type": "null" } ] }, - "defaultThreadEnvMode": { + "role": { "anyOf": [ { "anyOf": [ { "type": "string", "enum": [ - "local", - "worktree" + "architect", + "builder" ] }, { @@ -7497,7 +8528,7 @@ } ] }, - "faviconPath": { + "parentThreadId": { "anyOf": [ { "anyOf": [ @@ -7514,25 +8545,21 @@ } ] }, - "scripts": { - "anyOf": [ - { - "type": "array", - "items": { - "$ref": "#/$defs/OrchestrationEvent__Objects_3" - } - }, - { - "type": "null" - } - ] + "createdAt": { + "type": "string" }, "updatedAt": { "type": "string" } }, "required": [ + "threadId", "projectId", + "title", + "modelSelection", + "branch", + "worktreePath", + "createdAt", "updatedAt" ], "additionalProperties": false @@ -7623,22 +8650,138 @@ "type": { "type": "string", "enum": [ - "project.deleted" + "codev.gate-set" ] }, "payload": { "type": "object", "properties": { - "projectId": { + "threadId": { "type": "string" }, - "deletedAt": { + "gate": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { "type": "string" } }, "required": [ - "projectId", - "deletedAt" + "threadId", + "gate", + "gateRevision", + "updatedAt" ], "additionalProperties": false } @@ -7728,7 +8871,7 @@ "type": { "type": "string", "enum": [ - "thread.created" + "codev.gate-cleared" ] }, "payload": { @@ -7737,80 +8880,21 @@ "threadId": { "type": "string" }, - "projectId": { - "type": "string" - }, - "title": { - "type": "string" - }, - "modelSelection": { - "$ref": "#/$defs/OrchestrationEvent__Objects_2" - }, - "runtimeMode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access" - ] - }, - { - "type": "null" - } - ] - }, - "interactionMode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "default", - "plan" - ] - }, - { - "type": "null" - } - ] - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "worktreePath": { - "anyOf": [ - { - "type": "string" - }, + "gateRevision": { + "type": "integer", + "allOf": [ { - "type": "null" + "minimum": 0 } ] }, - "createdAt": { - "type": "string" - }, "updatedAt": { "type": "string" } }, "required": [ "threadId", - "projectId", - "title", - "modelSelection", - "branch", - "worktreePath", - "createdAt", + "gateRevision", "updatedAt" ], "additionalProperties": false @@ -11508,6 +12592,44 @@ } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" } @@ -12541,6 +13663,34 @@ "additionalProperties": false } ] + }, + "OrchestrationDispatchRefusal": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "parent-not-found", + "parent-in-other-project", + "parent-is-self", + "parent-not-architect", + "builder-without-parent", + "parent-on-non-builder", + "CODEV_GATE_REVISION_STALE", + "CODEV_GATE_THREAD_NOT_FOUND", + "CODEV_GATE_WRITE_UNCONFIRMED", + "CODEV_GATE_WRITE_FAILED" + ] + } + }, + "required": [ + "tag", + "reason" + ], + "additionalProperties": false } } } diff --git a/packages/types/src/t3/generated/schema.ts b/packages/types/src/t3/generated/schema.ts index 3657ce5a0..6ead6c4af 100644 --- a/packages/types/src/t3/generated/schema.ts +++ b/packages/types/src/t3/generated/schema.ts @@ -1,5 +1,6 @@ // GENERATED by tools/t3-codegen — do not edit. -// Source: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 +// Generated from: https://github.com/pseudoseed/t3code.git @ 2f64a1b0ee2b35cd858a8b601b4d425216e73ae5 (a private modified copy) +// Which branched from: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 // // A LOWER BOUND on t3code's validation, not an equivalent. See LOSSY.md. @@ -118,6 +119,112 @@ export const t3Defs = { "additionalProperties": false }, "subscribeThreadOutput__Objects_3": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "subscribeThreadOutput__Objects_4": { "type": "object", "properties": { "requestId": { @@ -176,7 +283,7 @@ export const t3Defs = { } ] }, - "subscribeThreadOutput__Objects_4": { + "subscribeThreadOutput__Objects_5": { "type": "object", "properties": { "id": { @@ -238,7 +345,7 @@ export const t3Defs = { ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_5": { + "subscribeThreadOutput__Objects_6": { "type": "object", "properties": { "path": { @@ -272,7 +379,7 @@ export const t3Defs = { ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_6": { + "subscribeThreadOutput__Objects_7": { "type": "object", "properties": { "threadId": { @@ -360,7 +467,7 @@ export const t3Defs = { ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_7": { + "subscribeThreadOutput__Objects_8": { "type": "object", "properties": { "providerTurnId": { @@ -454,7 +561,7 @@ export const t3Defs = { }, "additionalProperties": false }, - "subscribeThreadOutput__Objects_8": { + "subscribeThreadOutput__Objects_9": { "type": "object", "properties": { "canonicalKey": { @@ -505,7 +612,7 @@ export const t3Defs = { ], "additionalProperties": false }, - "subscribeThreadOutput__Objects_9": { + "subscribeThreadOutput__Objects_10": { "type": "object", "properties": { "id": { @@ -1228,6 +1335,44 @@ export const t3Schemas = { } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" } @@ -2498,6 +2643,83 @@ export const t3Schemas = { } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "codevGate": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "gateRevision": { + "anyOf": [ + { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" }, @@ -2632,7 +2854,7 @@ export const t3Schemas = { { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + "$ref": "#/$defs/subscribeThreadOutput__Objects_4" }, { "type": "null" @@ -2800,7 +3022,7 @@ export const t3Schemas = { "activities": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_4" + "$ref": "#/$defs/subscribeThreadOutput__Objects_5" } }, "checkpoints": { @@ -2833,7 +3055,7 @@ export const t3Schemas = { "files": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_5" + "$ref": "#/$defs/subscribeThreadOutput__Objects_6" } }, "assistantMessageId": { @@ -2865,7 +3087,7 @@ export const t3Schemas = { "session": { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_6" + "$ref": "#/$defs/subscribeThreadOutput__Objects_7" }, { "type": "null" @@ -3029,7 +3251,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3054,7 +3276,7 @@ export const t3Schemas = { { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + "$ref": "#/$defs/subscribeThreadOutput__Objects_9" }, { "type": "null" @@ -3096,7 +3318,7 @@ export const t3Schemas = { "scripts": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_9" + "$ref": "#/$defs/subscribeThreadOutput__Objects_10" } }, "createdAt": { @@ -3198,7 +3420,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3237,7 +3459,7 @@ export const t3Schemas = { { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + "$ref": "#/$defs/subscribeThreadOutput__Objects_9" }, { "type": "null" @@ -3309,7 +3531,7 @@ export const t3Schemas = { { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_9" + "$ref": "#/$defs/subscribeThreadOutput__Objects_10" } }, { @@ -3408,7 +3630,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3513,7 +3735,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -3586,6 +3808,44 @@ export const t3Schemas = { } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" }, @@ -3686,12 +3946,12 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", "enum": [ - "thread.deleted" + "codev.gate-set" ] }, "payload": { @@ -3700,13 +3960,26 @@ export const t3Schemas = { "threadId": { "type": "string" }, - "deletedAt": { + "gate": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { "type": "string" } }, "required": [ "threadId", - "deletedAt" + "gate", + "gateRevision", + "updatedAt" ], "additionalProperties": false } @@ -3791,12 +4064,231 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", "enum": [ - "thread.archived" + "codev.gate-cleared" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "gateRevision", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + }, + "type": { + "type": "string", + "enum": [ + "thread.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "deletedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" + }, + "type": { + "type": "string", + "enum": [ + "thread.archived" ] }, "payload": { @@ -3900,7 +4392,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4005,7 +4497,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4114,7 +4606,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4227,7 +4719,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4340,7 +4832,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4453,7 +4945,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4572,7 +5064,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4677,7 +5169,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4786,7 +5278,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -4838,7 +5330,7 @@ export const t3Schemas = { { "anyOf": [ { - "$ref": "#/$defs/subscribeThreadOutput__Objects_3" + "$ref": "#/$defs/subscribeThreadOutput__Objects_4" }, { "type": "null" @@ -5002,7 +5494,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5117,7 +5609,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5236,7 +5728,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5390,7 +5882,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5559,7 +6051,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5674,7 +6166,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5794,7 +6286,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -5907,7 +6399,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6021,7 +6513,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6131,7 +6623,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6236,7 +6728,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6251,7 +6743,7 @@ export const t3Schemas = { "type": "string" }, "session": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_6" + "$ref": "#/$defs/subscribeThreadOutput__Objects_7" } }, "required": [ @@ -6341,7 +6833,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6512,7 +7004,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6551,7 +7043,7 @@ export const t3Schemas = { "files": { "type": "array", "items": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_5" + "$ref": "#/$defs/subscribeThreadOutput__Objects_6" } }, "assistantMessageId": { @@ -6661,7 +7153,7 @@ export const t3Schemas = { ] }, "metadata": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_7" + "$ref": "#/$defs/subscribeThreadOutput__Objects_8" }, "type": { "type": "string", @@ -6676,7 +7168,7 @@ export const t3Schemas = { "type": "string" }, "activity": { - "$ref": "#/$defs/subscribeThreadOutput__Objects_4" + "$ref": "#/$defs/subscribeThreadOutput__Objects_5" } }, "required": [ @@ -6795,42 +7287,262 @@ export const t3Schemas = { ], "additionalProperties": false }, - "VcsCreateWorktreeInput": { - "type": "object", - "properties": { - "cwd": { - "type": "string" - }, - "refName": { - "type": "string" - }, - "newRefName": { - "anyOf": [ - { - "type": "string" + "CodevGateWriteInput": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "codev.gate.set" + ] }, - { - "type": "null" - } - ] - }, - "baseRefName": { - "anyOf": [ - { + "commandId": { "type": "string" }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { + "threadId": { "type": "string" }, - { - "type": "null" + "gate": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "revision": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "gate", + "createdAt" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "codev.gate.clear" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "revision": { + "anyOf": [ + { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "createdAt" + ], + "additionalProperties": false + } + ] + }, + "CodevGateWriteResult": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "cleared": { + "type": "boolean" + } + }, + "required": [ + "threadId", + "gateRevision", + "cleared" + ], + "additionalProperties": false + }, + "VcsCreateWorktreeInput": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "refName": { + "type": "string" + }, + "newRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } ] } @@ -7464,32 +8176,352 @@ export const t3Schemas = { } ] }, - "defaultModelSelection": { + "defaultModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationEvent__Objects_2" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "faviconPath": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "scripts": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/OrchestrationEvent__Objects_3" + } + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "updatedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/OrchestrationEvent__Objects_" + }, + "type": { + "type": "string", + "enum": [ + "project.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "deletedAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "deletedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "sequence", + "eventId", + "aggregateKind", + "aggregateId", + "occurredAt", + "commandId", + "causationEventId", + "correlationId", + "metadata", + "type", + "payload" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "eventId": { + "type": "string" + }, + "aggregateKind": { + "type": "string", + "enum": [ + "project", + "thread" + ] + }, + "aggregateId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "occurredAt": { + "type": "string" + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "causationEventId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "correlationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/OrchestrationEvent__Objects_" + }, + "type": { + "type": "string", + "enum": [ + "thread.created" + ] + }, + "payload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/OrchestrationEvent__Objects_2" + }, + "runtimeMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + }, + "interactionMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + { + "type": "null" + } + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { "anyOf": [ { - "anyOf": [ - { - "$ref": "#/$defs/OrchestrationEvent__Objects_2" - }, - { - "type": "null" - } - ] + "type": "string" }, { "type": "null" } ] }, - "defaultThreadEnvMode": { + "role": { "anyOf": [ { "anyOf": [ { "type": "string", "enum": [ - "local", - "worktree" + "architect", + "builder" ] }, { @@ -7502,7 +8534,7 @@ export const t3Schemas = { } ] }, - "faviconPath": { + "parentThreadId": { "anyOf": [ { "anyOf": [ @@ -7519,25 +8551,21 @@ export const t3Schemas = { } ] }, - "scripts": { - "anyOf": [ - { - "type": "array", - "items": { - "$ref": "#/$defs/OrchestrationEvent__Objects_3" - } - }, - { - "type": "null" - } - ] + "createdAt": { + "type": "string" }, "updatedAt": { "type": "string" } }, "required": [ + "threadId", "projectId", + "title", + "modelSelection", + "branch", + "worktreePath", + "createdAt", "updatedAt" ], "additionalProperties": false @@ -7628,22 +8656,138 @@ export const t3Schemas = { "type": { "type": "string", "enum": [ - "project.deleted" + "codev.gate-set" ] }, "payload": { "type": "object", "properties": { - "projectId": { + "threadId": { "type": "string" }, - "deletedAt": { + "gate": { + "type": "object", + "properties": { + "gateName": { + "type": "string" + }, + "requestedAt": { + "type": "string" + }, + "question": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "choices": { + "anyOf": [ + { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "consequence": { + "type": "string" + }, + "recommended": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "consequence" + ], + "additionalProperties": false + }, + "allOf": [ + { + "minItems": 1 + }, + { + "maxItems": 5 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "terminalExcerpt": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "maxLength": 8000 + } + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "gateName", + "requestedAt" + ], + "additionalProperties": false + }, + "gateRevision": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updatedAt": { "type": "string" } }, "required": [ - "projectId", - "deletedAt" + "threadId", + "gate", + "gateRevision", + "updatedAt" ], "additionalProperties": false } @@ -7733,7 +8877,7 @@ export const t3Schemas = { "type": { "type": "string", "enum": [ - "thread.created" + "codev.gate-cleared" ] }, "payload": { @@ -7742,80 +8886,21 @@ export const t3Schemas = { "threadId": { "type": "string" }, - "projectId": { - "type": "string" - }, - "title": { - "type": "string" - }, - "modelSelection": { - "$ref": "#/$defs/OrchestrationEvent__Objects_2" - }, - "runtimeMode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "approval-required", - "auto-accept-edits", - "auto", - "full-access" - ] - }, - { - "type": "null" - } - ] - }, - "interactionMode": { - "anyOf": [ - { - "type": "string", - "enum": [ - "default", - "plan" - ] - }, - { - "type": "null" - } - ] - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "worktreePath": { - "anyOf": [ - { - "type": "string" - }, + "gateRevision": { + "type": "integer", + "allOf": [ { - "type": "null" + "minimum": 0 } ] }, - "createdAt": { - "type": "string" - }, "updatedAt": { "type": "string" } }, "required": [ "threadId", - "projectId", - "title", - "modelSelection", - "branch", - "worktreePath", - "createdAt", + "gateRevision", "updatedAt" ], "additionalProperties": false @@ -11513,6 +12598,44 @@ export const t3Schemas = { } ] }, + "role": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": [ + "architect", + "builder" + ] + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "parentThreadId": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "createdAt": { "type": "string" } @@ -12546,6 +13669,34 @@ export const t3Schemas = { "additionalProperties": false } ] + }, + "OrchestrationDispatchRefusal": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "parent-not-found", + "parent-in-other-project", + "parent-is-self", + "parent-not-architect", + "builder-without-parent", + "parent-on-non-builder", + "CODEV_GATE_REVISION_STALE", + "CODEV_GATE_THREAD_NOT_FOUND", + "CODEV_GATE_WRITE_UNCONFIRMED", + "CODEV_GATE_WRITE_FAILED" + ] + } + }, + "required": [ + "tag", + "reason" + ], + "additionalProperties": false } } as const; @@ -12570,6 +13721,11 @@ export const t3Methods = { "output": "searchThreadsOutput", "stream": false }, + "codev.gateWrite": { + "input": "CodevGateWriteInput", + "output": "CodevGateWriteResult", + "stream": false + }, "vcs.createWorktree": { "input": "VcsCreateWorktreeInput", "output": "VcsCreateWorktreeResult", diff --git a/packages/types/src/t3/generated/source-hash.json b/packages/types/src/t3/generated/source-hash.json index d15ef4637..ea7d143bf 100644 --- a/packages/types/src/t3/generated/source-hash.json +++ b/packages/types/src/t3/generated/source-hash.json @@ -1,15 +1,37 @@ { - "commit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "commit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", "algorithm": "sha256", "files": { - "auth.ts": "b76da11bb49d5dde810b96dcb40bc9c2d208d649e8746382e54e56566e3be6af", + "auth.ts": "a2a8f6bd76102cfa11c62bc2d55d93bdae3d4a94ff71886607991ae2ab362304", "baseSchemas.ts": "70b8e290a3b3c898766eb74dfab5299dbf547a5f0817e180d4ad3463462da719", "environment.ts": "7aef220a852e35cdc6538ba439742d51d51ec27619dddf326764ac789a081a63", "git.ts": "0d95b17b6aba4e808951a94c734c398c877e1a334572db659a815d093b3bdabb", "model.ts": "0749c9085481e3500f156646d4216fb78436764272d97480e059a193fc6982fc", - "orchestration.ts": "6a03ed579719c74d35e68ea41e5ecc082d38a129dd3f030306ccd0d4159a7631", + "orchestration.ts": "e884b0154d92e2af032594e08a01977a20faade3d717dfbeead3a895a550d2a6", "providerInstance.ts": "0a1fea758707473021ff0e340015a464ff1af785a4bb842b8976a574acb0afaf", "sourceControl.ts": "e58dc3e8612be6e16bf25d9ee13a9c5d8a02807995f086abe544558544cd7f85", "vcs.ts": "41470d7316088e6fd85710df794076bc6311ca851816856c028a24a8cd4e63db" + }, + "upstream": { + "commit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "available": true, + "files": { + "auth.ts": "b76da11bb49d5dde810b96dcb40bc9c2d208d649e8746382e54e56566e3be6af", + "baseSchemas.ts": "70b8e290a3b3c898766eb74dfab5299dbf547a5f0817e180d4ad3463462da719", + "environment.ts": "7aef220a852e35cdc6538ba439742d51d51ec27619dddf326764ac789a081a63", + "git.ts": "0d95b17b6aba4e808951a94c734c398c877e1a334572db659a815d093b3bdabb", + "model.ts": "0749c9085481e3500f156646d4216fb78436764272d97480e059a193fc6982fc", + "orchestration.ts": "6a03ed579719c74d35e68ea41e5ecc082d38a129dd3f030306ccd0d4159a7631", + "providerInstance.ts": "0a1fea758707473021ff0e340015a464ff1af785a4bb842b8976a574acb0afaf", + "sourceControl.ts": "e58dc3e8612be6e16bf25d9ee13a9c5d8a02807995f086abe544558544cd7f85", + "vcs.ts": "41470d7316088e6fd85710df794076bc6311ca851816856c028a24a8cd4e63db" + } + }, + "forkDrift": { + "measured": true, + "changedFiles": [ + "auth.ts", + "orchestration.ts" + ] } } diff --git a/packages/types/src/t3/generated/types.d.ts b/packages/types/src/t3/generated/types.d.ts index 4ae0f487f..d687d845a 100644 --- a/packages/types/src/t3/generated/types.d.ts +++ b/packages/types/src/t3/generated/types.d.ts @@ -1,5 +1,6 @@ // GENERATED by tools/t3-codegen — do not edit. -// Source: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 +// Generated from: https://github.com/pseudoseed/t3code.git @ 2f64a1b0ee2b35cd858a8b601b4d425216e73ae5 (a private modified copy) +// Which branched from: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 // // Derived from the emitted JSON Schema, not from the Effect source, so these // declarations reference no runtime library and `packages/types` keeps zero @@ -64,6 +65,8 @@ export type dispatchCommandInput = { readonly "interactionMode"?: "default" | "plan" | null; readonly "branch": string | null; readonly "worktreePath": string | null; + readonly "role"?: "architect" | "builder" | null | null; + readonly "parentThreadId"?: string | null | null; readonly "createdAt": string; } | { readonly "type": "thread.delete"; @@ -286,6 +289,20 @@ export type subscribeThreadOutput = { readonly "planId": string; } | null; } | null; + readonly "role"?: "architect" | "builder" | null | null; + readonly "parentThreadId"?: string | null | null; + readonly "codevGate"?: { + readonly "gateName": string; + readonly "requestedAt": string; + readonly "question"?: string | null | null; + readonly "choices"?: ReadonlyArray<{ + readonly "label": string; + readonly "consequence": string; + readonly "recommended"?: boolean | null; + }> | null | null; + readonly "terminalExcerpt"?: string | null | null; + } | null | null; + readonly "gateRevision"?: number | null | null; readonly "createdAt": string; readonly "updatedAt": string; readonly "archivedAt"?: string | null | null; @@ -543,9 +560,74 @@ export type subscribeThreadOutput = { readonly "interactionMode"?: "default" | "plan" | null; readonly "branch": string | null; readonly "worktreePath": string | null; + readonly "role"?: "architect" | "builder" | null | null; + readonly "parentThreadId"?: string | null | null; readonly "createdAt": string; readonly "updatedAt": string; }; + } | { + readonly "sequence": number; + readonly "eventId": string; + readonly "aggregateKind": "project" | "thread"; + readonly "aggregateId": string | string; + readonly "occurredAt": string; + readonly "commandId": string | null; + readonly "causationEventId": string | null; + readonly "correlationId": string | null; + readonly "metadata": { + readonly "providerTurnId"?: string | null; + readonly "providerItemId"?: string | null; + readonly "adapterKey"?: string | null; + readonly "requestId"?: string | null; + readonly "ingestedAt"?: string | null; + readonly "origin"?: { + readonly "surface"?: "web" | "desktop" | "mobile" | null; + readonly "appVersion"?: string | null; + } | null; + }; + readonly "type": "codev.gate-set"; + readonly "payload": { + readonly "threadId": string; + readonly "gate": { + readonly "gateName": string; + readonly "requestedAt": string; + readonly "question"?: string | null | null; + readonly "choices"?: ReadonlyArray<{ + readonly "label": string; + readonly "consequence": string; + readonly "recommended"?: boolean | null; + }> | null | null; + readonly "terminalExcerpt"?: string | null | null; + }; + readonly "gateRevision": number; + readonly "updatedAt": string; + }; + } | { + readonly "sequence": number; + readonly "eventId": string; + readonly "aggregateKind": "project" | "thread"; + readonly "aggregateId": string | string; + readonly "occurredAt": string; + readonly "commandId": string | null; + readonly "causationEventId": string | null; + readonly "correlationId": string | null; + readonly "metadata": { + readonly "providerTurnId"?: string | null; + readonly "providerItemId"?: string | null; + readonly "adapterKey"?: string | null; + readonly "requestId"?: string | null; + readonly "ingestedAt"?: string | null; + readonly "origin"?: { + readonly "surface"?: "web" | "desktop" | "mobile" | null; + readonly "appVersion"?: string | null; + } | null; + }; + readonly "type": "codev.gate-cleared"; + readonly "payload": { + readonly "threadId": string; + readonly "gateRevision": number; + readonly "updatedAt": string; + }; } | { readonly "sequence": number; readonly "eventId": string; @@ -1305,6 +1387,37 @@ export type searchThreadsOutput = { }>; }; +export type CodevGateWriteInput = { + readonly "type": "codev.gate.set"; + readonly "commandId": string; + readonly "threadId": string; + readonly "gate": { + readonly "gateName": string; + readonly "requestedAt": string; + readonly "question"?: string | null | null; + readonly "choices"?: ReadonlyArray<{ + readonly "label": string; + readonly "consequence": string; + readonly "recommended"?: boolean | null; + }> | null | null; + readonly "terminalExcerpt"?: string | null | null; + }; + readonly "revision"?: number | null; + readonly "createdAt": string; +} | { + readonly "type": "codev.gate.clear"; + readonly "commandId": string; + readonly "threadId": string; + readonly "revision"?: number | null; + readonly "createdAt": string; +}; + +export type CodevGateWriteResult = { + readonly "threadId": string; + readonly "gateRevision": number; + readonly "cleared": boolean; +}; + export type VcsCreateWorktreeInput = { readonly "cwd": string; readonly "refName": string; @@ -1549,9 +1662,74 @@ export type OrchestrationEvent = { readonly "interactionMode"?: "default" | "plan" | null; readonly "branch": string | null; readonly "worktreePath": string | null; + readonly "role"?: "architect" | "builder" | null | null; + readonly "parentThreadId"?: string | null | null; readonly "createdAt": string; readonly "updatedAt": string; }; +} | { + readonly "sequence": number; + readonly "eventId": string; + readonly "aggregateKind": "project" | "thread"; + readonly "aggregateId": string | string; + readonly "occurredAt": string; + readonly "commandId": string | null; + readonly "causationEventId": string | null; + readonly "correlationId": string | null; + readonly "metadata": { + readonly "providerTurnId"?: string | null; + readonly "providerItemId"?: string | null; + readonly "adapterKey"?: string | null; + readonly "requestId"?: string | null; + readonly "ingestedAt"?: string | null; + readonly "origin"?: { + readonly "surface"?: "web" | "desktop" | "mobile" | null; + readonly "appVersion"?: string | null; + } | null; + }; + readonly "type": "codev.gate-set"; + readonly "payload": { + readonly "threadId": string; + readonly "gate": { + readonly "gateName": string; + readonly "requestedAt": string; + readonly "question"?: string | null | null; + readonly "choices"?: ReadonlyArray<{ + readonly "label": string; + readonly "consequence": string; + readonly "recommended"?: boolean | null; + }> | null | null; + readonly "terminalExcerpt"?: string | null | null; + }; + readonly "gateRevision": number; + readonly "updatedAt": string; + }; +} | { + readonly "sequence": number; + readonly "eventId": string; + readonly "aggregateKind": "project" | "thread"; + readonly "aggregateId": string | string; + readonly "occurredAt": string; + readonly "commandId": string | null; + readonly "causationEventId": string | null; + readonly "correlationId": string | null; + readonly "metadata": { + readonly "providerTurnId"?: string | null; + readonly "providerItemId"?: string | null; + readonly "adapterKey"?: string | null; + readonly "requestId"?: string | null; + readonly "ingestedAt"?: string | null; + readonly "origin"?: { + readonly "surface"?: "web" | "desktop" | "mobile" | null; + readonly "appVersion"?: string | null; + } | null; + }; + readonly "type": "codev.gate-cleared"; + readonly "payload": { + readonly "threadId": string; + readonly "gateRevision": number; + readonly "updatedAt": string; + }; } | { readonly "sequence": number; readonly "eventId": string; @@ -2339,6 +2517,8 @@ export type ClientOrchestrationCommand = { readonly "interactionMode"?: "default" | "plan" | null; readonly "branch": string | null; readonly "worktreePath": string | null; + readonly "role"?: "architect" | "builder" | null | null; + readonly "parentThreadId"?: string | null | null; readonly "createdAt": string; } | { readonly "type": "thread.delete"; @@ -2512,5 +2692,10 @@ export type ClientOrchestrationCommand = { readonly "onlyIfSettled"?: boolean | null; }; +export type OrchestrationDispatchRefusal = { + readonly "tag": string; + readonly "reason": "parent-not-found" | "parent-in-other-project" | "parent-is-self" | "parent-not-architect" | "builder-without-parent" | "parent-on-non-builder" | "CODEV_GATE_REVISION_STALE" | "CODEV_GATE_THREAD_NOT_FOUND" | "CODEV_GATE_WRITE_UNCONFIRMED" | "CODEV_GATE_WRITE_FAILED"; +}; + export interface T3Method { readonly input: string | null; readonly output: string | null; readonly stream: boolean; } -export type T3MethodName = "orchestration.dispatchCommand" | "orchestration.subscribeThread" | "orchestration.getTurnDiff" | "orchestration.searchThreads" | "vcs.createWorktree" | "vcs.removeWorktree" | "vcs.createRef" | "vcs.status"; +export type T3MethodName = "orchestration.dispatchCommand" | "orchestration.subscribeThread" | "orchestration.getTurnDiff" | "orchestration.searchThreads" | "codev.gateWrite" | "vcs.createWorktree" | "vcs.removeWorktree" | "vcs.createRef" | "vcs.status"; diff --git a/packages/types/src/t3/pin.json b/packages/types/src/t3/pin.json index ffaa98529..aaf1b9f63 100644 --- a/packages/types/src/t3/pin.json +++ b/packages/types/src/t3/pin.json @@ -1,8 +1,15 @@ { "_comment": "The pinned t3code contract. Edited only by the refresh procedure in tools/t3-codegen/REFRESH.md. Spec 146.", + "_identities": "Spec 250: two identities, not one. `commit` keeps its spec 146 meaning (the commit the generated artifacts came from) and that source is the FORK from phase 5 onward. `upstreamBase` is the pingdotgg commit the fork branched from, and the pin the read-only upstream clone must stay on. They are equal until the fork diverges, which is deliberate: while they are equal every two-identity assertion has a known answer.", "repo": "https://github.com/pingdotgg/t3code", - "commit": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", - "commitDate": "2026-08-25", + "commit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "upstreamBase": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", + "forkRepo": "https://github.com/pseudoseed/t3code.git", + "forkBranch": "codev", + "contractSource": "fork", + "commitDate": "2026-08-31", + "upstreamBaseDate": "2026-08-25", + "_dates": "One per identity, like the commits themselves. `commitDate` belongs to `commit` (the fork head the vendored contract was generated from) and `upstreamBaseDate` to `upstreamBase` (the pingdotgg commit the fork branched from). They were one field while the two commits were equal; phase 5 made them differ, and a single date would then have been right for one identity and wrong for the other with nothing to say which.", "cliVersion": "0.0.36", "effectVersion": "4.0.0-beta.103", "contractsRoot": "packages/contracts/src", @@ -19,7 +26,7 @@ ], "closureNote": "Transitive import closure of orchestration.ts + git.ts + auth.ts. 9 files, 3663 lines. rpc.ts is deliberately excluded: its own closure is 27 files / 11120 lines because it names every unrelated subsystem's RPCs. The generator FAILS if the real import graph reaches a file not on this list.", "methods": { - "_comment": "The mapping rpc.ts would have given us, pinned explicitly instead. Orchestration entries are DERIVED from OrchestrationRpcSchemas in the closure and verified against this list. The vcs method strings live in the unvendored rpc.ts, so they are recorded here and their schemas resolved from the vendored git.ts.", + "_comment": "The mapping rpc.ts would have given us, pinned explicitly instead. Orchestration entries are DERIVED from OrchestrationRpcSchemas in the closure and verified against this list. Entries with a `source` naming a vendored FILE are the ones whose method string lives in the unvendored rpc.ts while their schemas live in the closure: the vcs.* entries resolve from git.ts, and spec 250's codev.gateWrite from orchestration.ts. A method absent from this map is never vendored, however present it is in the contract — generate.mjs iterates these entries, not OrchestrationRpcSchemas.", "orchestration.dispatchCommand": { "key": "dispatchCommand", "source": "OrchestrationRpcSchemas" @@ -37,6 +44,11 @@ "key": "searchThreads", "source": "OrchestrationRpcSchemas" }, + "codev.gateWrite": { + "input": "CodevGateWriteInput", + "output": "CodevGateWriteResult", + "source": "orchestration.ts" + }, "vcs.createWorktree": { "input": "VcsCreateWorktreeInput", "output": "VcsCreateWorktreeResult", @@ -57,5 +69,6 @@ "output": "VcsStatusResult", "source": "git.ts" } - } + }, + "_contractSource": "Where the VENDORED CONTRACT under packages/types/src/t3/generated was generated from, which is not the same question as where the fork checkout is. 'upstream' meant the contract had not been regenerated from the fork yet, so a fork HEAD that DESCENDED from `commit` was expected and verify reported FORK_AHEAD_OF_CONTRACT at exit 0. Phase 5 regenerated from the fork and set this to 'fork', so being ahead is now an error: the checkout has moved past the contract and the contract must be regenerated. A fork HEAD that does not descend from `commit` is FORK_CHECKOUT_MISMATCH and an error either way. To abandon the fork, set `commit` back to `upstreamBase` and this back to 'upstream', then regenerate — the procedure is in tools/t3-fork/FORK.md." } diff --git a/packages/types/src/t3/shape-check.ts b/packages/types/src/t3/shape-check.ts index 62d4ab5d3..6deec2af6 100644 --- a/packages/types/src/t3/shape-check.ts +++ b/packages/types/src/t3/shape-check.ts @@ -112,6 +112,8 @@ const SUPPORTED = new Set([ 'maximum', 'minLength', 'maxLength', + 'minItems', + 'maxItems', '$ref', 'description', 'title', @@ -214,8 +216,25 @@ function check( } } - if (Array.isArray(value) && schema.items) { - value.forEach((item, index) => check(item, schema.items as JsonSchema, `${path}/${index}`, out, defs, seen, excess)); + if (Array.isArray(value)) { + /** + * Spec 250 phase 5. The generator emits these for a bounded array — the gate + * payload's one-to-five choices is the first schema in the vendored closure + * to have them — and an unimplemented keyword THROWS rather than passing, so + * without this every check of that payload raised `UnsupportedKeywordError` + * instead of returning a result. Implementing them makes the checker report + * on a constraint it previously refused to look at; it is not a loosening, + * and it changes nothing for any schema that does not carry them. + */ + if (typeof schema.minItems === 'number' && value.length < schema.minItems) { + out.push({ path, expected: `minItems ${schema.minItems}`, actual: `length ${value.length}` }); + } + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) { + out.push({ path, expected: `maxItems ${schema.maxItems}`, actual: `length ${value.length}` }); + } + if (schema.items) { + value.forEach((item, index) => check(item, schema.items as JsonSchema, `${path}/${index}`, out, defs, seen, excess)); + } } if (value !== null && typeof value === 'object' && !Array.isArray(value)) { diff --git a/tools/t3-codegen/REFRESH.md b/tools/t3-codegen/REFRESH.md index c33f233da..cc89d6e87 100644 --- a/tools/t3-codegen/REFRESH.md +++ b/tools/t3-codegen/REFRESH.md @@ -3,6 +3,28 @@ Spec 146 requires a documented refresh procedure and a test that fails when the vendored copy drifts from the pinned server. This is the procedure. +## Two identities (spec 250) + +There are now **two** checkouts and refreshing means moving two pins, not one. See +`tools/t3-fork/FORK.md` for the full mapping. + +| | Upstream | Fork | +|---|---|---| +| Checkout | `$T3CODE_ROOT`, default `/Users/chris/dev/t3code` | `$T3CODE_FORK_ROOT`, default `/Users/chris/dev/t3code-codev` | +| Pinned by | `pin.upstreamBase` | `pin.commit` | +| Role | the public tree we branched from, **read-only** | the private customization the artifacts are generated from | + +`pin.commit` keeps its spec 146 meaning: the commit the generated artifacts came from. That +commit is the fork's from phase 5 onward, so **generation reads the fork** and +`source-hash.json` records the upstream closure alongside it. Comparing the fork's hashes to +the fork they came from proves only that the generator is deterministic; the `upstream` +section is the other end of the comparison, and `forkDrift.changedFiles` is the subtraction. + +A refresh moves `upstreamBase` (upstream released something) and then moves `commit` (our +branch was rebased onto it). Moving one without the other leaves a fork whose merge-base is +no longer `upstreamBase`, and `t3-server.mjs verify` fails with `FORK_BASE_MISMATCH` rather +than letting a meaningless drift range be computed from it. + ## What you are refreshing Nine files, 3,663 lines — the transitive import closure of `orchestration.ts`, `git.ts` and @@ -50,40 +72,213 @@ It is not a false positive and it is not a formatting nit. Read the diff. git -C "$T3CODE_ROOT" log --oneline -20 ``` -2. Check what actually changed in the closure since the current pin: +2. Check what actually changed in the closure. There are two questions and `classify-churn` + refuses to guess which one you meant: ```bash cd tools/t3-codegen - node classify-churn.mjs --since "$(node -p "require('../../packages/types/src/t3/pin.json').commit")" + node classify-churn.mjs --upstream-movement # upstreamBase..origin/main, from the upstream clone + node classify-churn.mjs --fork-drift # upstreamBase.., from the fork checkout ``` - This replays each commit touching the closure and reports whether it changed a shape Codev - consumes. Read the output before moving the pin, not after. + Each replays every commit touching the closure in its range and reports whether it changed a + shape Codev consumes. Read both before moving either pin, not after. + + An empty `--upstream-movement` reports `NO_UPSTREAM_MOVEMENT` and exits `0`: upstream has not + moved. That is a different answer from the tool failing (`1`, a bad invocation) and from it + being unable to read a checkout or a ref (`3`). Invoked with no mode, or with both, it exits + `1` and classifies nothing. -3. Move the pin. Edit `packages/types/src/t3/pin.json`: `commit`, `commitDate`, and - `effectVersion` if t3code's catalog moved. If `effectVersion` changed, update the +3. Move the pins. Edit `packages/types/src/t3/pin.json`: `upstreamBase` to the new upstream + commit and `upstreamBaseDate` to its date, `commit` to the fork head that now sits on top of + it and `commitDate` to *its* date, plus `effectVersion` if t3code's catalog moved. There is one + date per identity because the two commits are no longer the same commit — a single date would + be right for one and wrong for the other. If `effectVersion` changed, update the `devDependencies` in `tools/t3-codegen/package.json` to match and reinstall — generating with a different Effect than the server was built against produces artifacts that describe nothing real. -4. Check out the pinned commit and regenerate: + `pin.methods` is the vendoring list, and it is not derived: `generate.mjs` iterates it, so a + method the fork adds and this map does not name is silently never vendored. An entry whose + `source` names a vendored file (`git.ts` for `vcs.*`, `orchestration.ts` for + `codev.gateWrite`) is one whose method string lives in the unvendored `rpc.ts` — those are + hand-recorded on purpose. `codev.gateWrite`'s schemas exist only in the fork; a refresh that + moves the pin back to upstream must remove it or generation fails. + +4. Put both checkouts on their pins and regenerate: ```bash - git -C "$T3CODE_ROOT" checkout + git -C "$T3CODE_ROOT" checkout + git -C "$T3CODE_FORK_ROOT" checkout + node ../t3-server/t3-server.mjs verify # exits 0 only when both are clean on their pins + # (verify-upstream / verify-fork check one each) pnpm --filter @cluesmith/t3-codegen generate ``` + Generation reads the fork and hashes the upstream clone for comparison. If the upstream clone + is absent or off its base, `source-hash.json` records `upstream.available: false` with a + reason and the live upstream suite fails rather than accepting an unmeasured section as a + match. + 5. Read `generated/LOSSY.md` and `generated/UNREPRESENTED.md`. An entry appearing in UNREPRESENTED that Codev consumes is a **blocker**: there is no JSON Schema for it, so `shapeCheck` cannot check it in any form. Raise it rather than shipping. -6. Run the suite. `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` verifies the - hashes against the checkout, so a stale regeneration fails here. +6. Run the suite with **both** roots exported. `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` + verifies the upstream hashes against `$T3CODE_ROOT` and the generated hashes against + `$T3CODE_FORK_ROOT`, in two separately-gated suites, so a stale regeneration fails here and a + run missing one checkout reports that suite as skipped rather than passing it. + +7. Re-export the review patches, because they are cut against `pin.commit`: + + ```bash + rm -f ../t3-fork/patches/*.patch + git -C "$T3CODE_FORK_ROOT" format-patch --no-signature \ + -o "$(cd ../t3-fork/patches && pwd)" .. \ + -- . ':(exclude)docs/codev' + ``` + + Two things that bite. `-o` resolves relative to `-C`, so a relative path writes the patches + INTO the fork checkout, where they are untracked litter that makes `start-fork` refuse the next + run. And `docs/codev` is excluded because it holds UI screenshots: a screenshot in a patch is a + base64 blob, unreadable by the human the patches exist for and rewritten whole on every + re-shoot. Commits that touch only that directory therefore produce no patch, so the numbering + has gaps — `tools/t3-fork/FORK.md`'s phase log is the complete list of fork commits. + + They are a **review aid** — the customization readable by someone without the private + repository — and not how the fork is built or rebased. `tools/t3-fork/FORK.md` says so + there too. + +8. **Re-run every evidence run that names a fork commit.** Moving `commit` invalidates all four at + once, and `collect-spec-250-evidence.mjs` refuses with `STALE_RUN` rather than publishing a + number about a fork nobody is looking at: + + ```bash + node tools/t3-fork/criterion-8b.mjs --out codev/research/250-criterion-8b-evidence.json + node packages/t3-client/live/spec-250-hierarchy.mjs --out codev/research/250-hierarchy-wire-evidence.json + node tools/t3-codegen/classify-churn.mjs --upstream-movement --out codev/research/250-upstream-movement.json + node tools/t3-fork/rebase-drill.mjs --out codev/research/250-rebase-drill.json + node tools/t3-server/collect-spec-250-evidence.mjs # then --check, which must exit 0 + ``` + + **This step is the one that gets forgotten**, because a fork commit that touches no closure file + changes nothing in the generated contract except a sha — so regeneration looks like the whole + job, and the acceptance evidence quietly goes on describing the previous fork. + + Three of these start a server. **`T3_HARNESS_PORT` is not optional in practice**: other sessions + leave servers on the default 3799 and on 3823, and the harness refuses to kill what it cannot + prove it owns. Check with `lsof -nP -iTCP: -sTCP:LISTEN` — a `/dev/tcp` probe reports every + port free under zsh, which does not implement that redirection, so it is a check that cannot fail. + + Also re-run the spec-250 Playwright suite, for the same reason: its results describe the fork head + they ran against, and criterion 1, 2, 3, 5, 5b and 7 rest on them. -7. Commit the pin and the regenerated artifacts **together**. A pin without its artifacts, or +9. Commit the pin and the regenerated artifacts **together**. A pin without its artifacts, or artifacts without their pin, is worse than either alone — the drift test then compares against something nobody chose. +## The rebase drill (spec 250, phase 11) + +**Before you carry the customization onto a later upstream, measure the job.** The drill is a +script rather than a hand procedure, because criterion 9 is about the procedure being repeatable +and a rebase performed once by hand proves an event: + +```bash +export PATH=$HOME/.nvm/versions/node/v22.22.2/bin:$PATH +git -C "$T3CODE_ROOT" fetch origin # allowed: refs move, HEAD does not +node tools/t3-codegen/classify-churn.mjs --upstream-movement +node tools/t3-fork/rebase-drill.mjs --out codev/research/250-rebase-drill.json +``` + +**Nothing real moves, and the drill checks that rather than promising it.** It works in a scratch +clone and re-reads both checkouts afterwards; if `T3CODE_ROOT` left `upstreamBase`, if the fork +head moved, or if `pin.commit` changed, it **discards its own result** — a drill that disturbed +the thing it was meant to leave alone cannot be trusted about anything else. + +**`pin.json` is NOT advanced by the drill.** The moment it names a new base, `verify-upstream` +expects the preserved clone to be there and every spec 146 and 236 result tied to the old base +stops being re-runnable. Advancing the base is a decision taken when there is a reason — a +security fix, a feature we need — never to satisfy a phase. + +It reports these, and they answer different questions: + +| Field | Question | +|---|---| +| `upstreamChurn` | how far upstream has moved, and how much of that touches the pinned closure | +| `stoppedAt` + `conflictedFiles` | where does a sequential `git rebase` stop | +| `wholeSurface.conflictedFiles` | how much conflicts IN TOTAL — a rebase stops at the first, so the first understates the job every time | +| `contractClosure.regenerationReachable` | can the vendored contract be regenerated afterwards, or is it stranded behind the conflicts | +| `contractClosure.sourceHash.moved` | would the regenerated contract be the one we vendored — which closure files the merged tree hands the generator with different bytes | +| `contractRegeneration` | did the contract REGENERATE from the rebased tree, and do the shapes Codev consumes still match the vendored ones. See below | +| `watermark` | does every migration upstream added land ABOVE the watermark our base leaves | + +**The drill DOES regenerate the contract, in a second throwaway, and it does it without moving +anything.** `generate.mjs` refuses any checkout whose `HEAD` is not `pin.commit`, so pointed at this +repository "regenerate from the rebased tree" would mean moving the real pin — which is step 3 below, +taken when a rebase is adopted for a reason. The way around that is not to loosen the guard: + +1. `git merge-tree --write-tree` plus `git commit-tree` give the merged tree an identity **inside the + throwaway clone**. A sequential rebase stops at the first conflict, so there is usually no rebased + HEAD; the generator reads only the closure, and the closure is usually clean. +2. A **scratch codegen root** is assembled beside it — `generate.mjs` resolves `pin.json`, its output + directory and its staging area from its own file location, so a copy of the tool under a scratch + directory reads a scratch pin naming the merged commit. The guard is satisfied honestly: the + artifacts really are reproducible from the commit they name. +3. The output is compared byte for byte to the artifacts **vendored in this repository**, never to + what the scratch run just wrote. + +A regenerated contract that differs is a **result**, not a failure — it is what adopting the base +costs. `shapesDiffering` is the load-bearing list; `embedsCommitId` names the two artifacts that +carry the commit id and would differ after any rebase. + +**The generator needs Node >= 22** (it imports the closure's TypeScript). The drill itself runs under +20. An interpreter that cannot run it reports `attempted: false` with `NO_INTERPRETER` — never "the +contract does not regenerate", which would be a claim about the fork made from a fact about this +machine. `T3_CODEGEN_NODE` overrides. + +The generator's *inputs* are still measured alongside: `contractClosure.conflicted` says whether the +generator would find its source, and `contractClosure.sourceHash.moved` says whether that source +still hashes to what the vendored contract came from. + +A `could-not-run` result carries none of these fields. That is deliberate: it means nothing was +learned, and a measurement-shaped field on such a document is the first thing a reader would mistake +for a finding. Its `reason` is the whole document. + +The hash is taken off the merged worktree **before** the probe merge is aborted. After the abort the +worktree is the fork again and the comparison is the fork against itself, which reports zero moved +files on every run forever. If you move that call, the test that holds it is +`packages/codev/src/__tests__/spec-250-rebase-drill.test.ts`. + +Outcomes: `ok` (including `NO_UPSTREAM_MOVEMENT`, which is a pass), `conflicts` — **a result, not a +failure; it is the number the drill exists to produce** — and `could-not-run`, which must never be +read as "no conflicts". Exit 0 for the first two, 3 for the last. + +### Result, 2026-08-31 + +Against upstream `9b2d04317c68`, 104 commits past `082e6ea52186`, carrying 42 customization +commits: + +- `classify-churn --upstream-movement`: the counts live in + `codev/research/250-upstream-movement.json` and are printed into the acceptance evidence by the + collector, so they cannot drift from the run. **Which** commits are undecidable is the part worth + writing down: the `orchestration.subscribeThread` and `orchestration.dispatchCommand` union + shapes, which are the two unions our customization adds members to. +- The sequential rebase stops at **commit 6 of 42** on `apps/server/src/server.test.ts`. +- The whole surface is **3 files of the 35 we modify**: that test, plus + `apps/web/src/components/Sidebar.tsx` and `Sidebar.logic.ts`. +- **`packages/contracts/src/orchestration.ts` auto-merged clean** — the file `FORK.md` rated High, + and the one upstream changed twice in the unions we extend. +- **The contract closure has zero conflicts**, so regeneration is reachable rather than stranded — + but **4 of the 9 closure files come out of the merge with different bytes** (`auth.ts`, + `baseSchemas.ts`, `environment.ts`, `orchestration.ts`), so the regenerated contract would not be + the one vendored. Not blocked and not unchanged: two facts that had been reading as one. +- **The contract REGENERATES from the rebased tree** — the generator completes — and + **`schema.json`, `schema.ts` and `types.d.ts` all move**. The shapes Codev consumes change when + this base is adopted, and that is now a run rather than an open question. +- **Watermark holds against a real new migration**: upstream added `043`, above the `042` our base + leaves. Phase 2 tested that invariant with a synthetic migration; this is the first time a real + one has arrived to test it with. + ## Verifying without regenerating ```bash @@ -96,7 +291,10 @@ Regenerates in memory and fails if anything on disk differs. This is what CI sho - Node 22 (`PATH=$HOME/.nvm/versions/node/v22.22.2/bin:$PATH`). The generator imports TypeScript contract files directly and relies on Node's type stripping. -- A t3code checkout at `$T3CODE_ROOT`, default `/Users/chris/dev/t3code`. -- The checkout is treated as **read-only**. The generator copies the closure into `.staging/` - rather than importing in place, both so `effect` resolves from this tool's own - `node_modules` and so nothing can ever write into the clone. +- An upstream t3code checkout at `$T3CODE_ROOT`, default `/Users/chris/dev/t3code`, and a fork + checkout at `$T3CODE_FORK_ROOT`, default `/Users/chris/dev/t3code-codev`. +- **Both** checkouts are treated as read-only by these tools. The generator copies the closure + into `.staging/` rather than importing in place, both so `effect` resolves from this tool's + own `node_modules` and so nothing can ever write into a clone. The one verb in the harness + that writes, `t3-server.mjs acquire`, targets the upstream clone and only ever checks out + `upstreamBase`. diff --git a/tools/t3-codegen/classify-churn.mjs b/tools/t3-codegen/classify-churn.mjs index 3b981ca37..0e2d8f918 100644 --- a/tools/t3-codegen/classify-churn.mjs +++ b/tools/t3-codegen/classify-churn.mjs @@ -23,32 +23,94 @@ * treating source-only as safe is exactly the mistake the two-layer design exists * to prevent. * + * --------------------------------------------------------------------------- + * Spec 250 — two ranges, two checkouts, two questions. + * + * "What has upstream done since we pinned it?" and "what have we changed?" are + * not the same question, and answering them from one range reports our own + * customization as upstream movement. So the mode is mandatory: + * + * --upstream-movement upstreamBase..origin/main, read from the upstream clone + * --fork-drift upstreamBase.., read from the fork checkout + * + * Invoked with neither, it fails. Invoked with both, it fails. There is no + * default, because the wrong default here produces a plausible-looking answer to + * a question nobody asked. + * * Usage: - * node classify-churn.mjs [--since ] [--limit N] + * node classify-churn.mjs (--upstream-movement | --fork-drift) [--since ] [--limit N] + * + * Exit codes: 0 ok (including "nothing to classify"), 1 bad invocation, + * 3 "could not determine" — a missing checkout or an unresolvable ref. */ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { CHURN_MODES, MISMATCH, UNDETERMINED, churnRange, resolveIdentities } from '../t3-fork/identities.mjs'; + const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); const pin = JSON.parse(readFileSync(join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'), 'utf8')); -const t3Root = process.env.T3CODE_ROOT ?? '/Users/chris/dev/t3code'; +const identities = resolveIdentities(pin); const args = process.argv.slice(2); + +const selected = Object.keys(CHURN_MODES).filter((m) => args.includes(`--${m}`)); +if (selected.length !== 1) { + console.error( + `[classify-churn] ${selected.length === 0 ? 'no mode given' : `${selected.length} modes given`}. ` + + `Pass exactly one of ${Object.keys(CHURN_MODES).map((m) => `--${m}`).join(' or ')}.\n` + + ` --upstream-movement what pingdotgg/t3code did since ${identities.upstream.commit.slice(0, 12)}\n` + + ` --fork-drift what our private customization changed\n` + + `They read different checkouts and mean different things; there is no default.`, + ); + process.exit(MISMATCH); +} + +const range = churnRange(selected[0], identities); +const t3Root = range.root; + +if (!existsSync(t3Root)) { + console.error( + `[classify-churn] COULD_NOT_TELL: no ${range.identity} checkout at ${t3Root}. ` + + `Nothing was classified, and that is not the same as nothing having changed.`, + ); + process.exit(UNDETERMINED); +} + +const git = (...a) => execFileSync('git', ['-C', t3Root, ...a], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + +const closurePaths = pin.closure.map((f) => `${pin.contractsRoot}/${f}`); + const sinceIdx = args.indexOf('--since'); -const since = sinceIdx >= 0 ? args[sinceIdx + 1] : '2026-02-07'; const limitIdx = args.indexOf('--limit'); const limit = limitIdx >= 0 ? Number(args[limitIdx + 1]) : Infinity; -const git = (...a) => execFileSync('git', ['-C', t3Root, ...a], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +// `--since` narrows the mode's range; it does not replace it. Overriding the +// start of an --upstream-movement range is a legitimate thing to want; silently +// letting it also change WHICH checkout is read is not. +const from = sinceIdx >= 0 ? args[sinceIdx + 1] : range.from; +const rangeSpec = `${from}..${range.to}`; -const closurePaths = pin.closure.map((f) => `${pin.contractsRoot}/${f}`); +// Resolved AFTER `--since` is applied, so the guard covers the refs actually +// used. Checking `range.from` here instead would let an unresolvable `--since` +// past it and surface as a raw git error, which is exit 1 wearing exit 3's job. +for (const ref of [from, range.to]) { + try { + git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + } catch { + console.error( + `[classify-churn] COULD_NOT_TELL: ${ref} does not resolve in ${t3Root}. ` + + `An unreadable ref is "unknown", not "no movement".`, + ); + process.exit(UNDETERMINED); + } +} -const sinceArg = /^[0-9a-f]{7,40}$/.test(since) ? `${since}..HEAD` : `--since=${since}`; -const commits = git('log', '--format=%H|%ad|%s', '--date=short', '--reverse', sinceArg, '--', ...closurePaths) +const commits = git('log', '--format=%H|%ad|%s', '--date=short', '--reverse', rangeSpec, '--', ...closurePaths) .trim() .split('\n') .filter(Boolean) @@ -63,12 +125,22 @@ if (commits.length === 0) { // current and nothing new has landed. Exiting non-zero here made the documented // refresh procedure's own step 2 fail whenever it had nothing to report, which // is "nothing to do" spelled exactly like "something went wrong". - console.error('[classify-churn] no commits touch the closure in that range — nothing to classify'); - console.log(JSON.stringify({ range: since, total: 0, counts: {}, rows: [] }, null, 2)); + // + // The signal names the mode, so "upstream has not moved" and "we have not + // customized anything yet" stay two readable answers rather than one blank one. + const signal = selected[0] === 'upstream-movement' ? 'NO_UPSTREAM_MOVEMENT' : 'NO_FORK_DRIFT'; + console.error(`[classify-churn] ${signal}: no commits touch the closure in ${rangeSpec} — nothing to classify`); + console.log(JSON.stringify({ + mode: selected[0], identity: range.identity, root: t3Root, range: rangeSpec, + signal, total: 0, counts: {}, rows: [], + }, null, 2)); process.exit(0); } -console.error(`[classify-churn] classifying ${commits.length} commits touching the closure...`); +console.error( + `[classify-churn] ${selected[0]}: classifying ${commits.length} commits touching the closure ` + + `in ${rangeSpec} (${range.identity} checkout ${t3Root})...`, +); /** * Emit the closure's consumed schemas at one commit, without touching the @@ -105,7 +177,12 @@ async function emitAt(sha) { const doc = (s) => JSON.stringify(SR.toJsonSchemaDocument(SR.toRepresentation(s.ast)).schema ?? {}); out[method] = doc(entry.input) + '|' + doc(entry.output); } else { - const doc = (n) => (n && gitMod[n] ? JSON.stringify(SR.toJsonSchemaDocument(SR.toRepresentation(gitMod[n].ast)).schema ?? {}) : ''); + // Same module map as generate.mjs. Hardcoding git.ts here would report + // `codev.gateWrite` as `` at every commit, which reads as "the + // method is not in the contract" rather than "this tool looked in the + // wrong file" — the two must not be spelled the same. + const mod = { 'git.ts': gitMod, 'orchestration.ts': orchestration }[spec.source]; + const doc = (n) => (n && mod?.[n] ? JSON.stringify(SR.toJsonSchemaDocument(SR.toRepresentation(mod[n].ast)).schema ?? {}) : ''); out[method] = doc(spec.input) + '|' + doc(spec.output); } } catch (error) { @@ -237,6 +314,34 @@ const rows = []; let previous = null; let previousSource = null; +/** + * Seed the comparison from the range's START commit. + * + * `git log from..to` EXCLUDES `from`, so without this the first commit in the + * range has nothing to diff against and is reported as `baseline` — a placeholder, + * not a verdict. For `--fork-drift` that is the whole answer: with a single + * customization commit the tool reported "baseline" and no drift, on the one + * question it exists to answer. + * + * `from` is a real commit in both modes (`upstreamBase`, or whatever `--since` + * named), so comparing the first row against it is the comparison the range + * already implies. Skipped when `--since` was given a DATE rather than a sha: + * there is no commit to emit at, and guessing one would be worse than the + * baseline row. + */ +if (/^[0-9a-f]{7,40}$/.test(from)) { + const seed = await emitAt(from); + if (seed.error) { + console.error( + `[classify-churn] could not emit at range start ${from.slice(0, 12)} (${seed.error}); ` + + `the first commit will be reported as \`baseline\` rather than compared.`, + ); + } else { + previous = seed.schemas; + previousSource = sourceOf(from); + } +} + for (const [index, commit] of commits.entries()) { const emitted = await emitAt(commit.sha); const source = sourceOf(commit.sha); @@ -291,5 +396,8 @@ for (const [index, commit] of commits.entries()) { const counts = rows.reduce((acc, r) => ({ ...acc, [r.verdict]: (acc[r.verdict] ?? 0) + 1 }), {}); -console.log(JSON.stringify({ range: since, total: rows.length, counts, rows }, null, 2)); +console.log(JSON.stringify({ + mode: selected[0], identity: range.identity, root: t3Root, range: rangeSpec, + total: rows.length, counts, rows, +}, null, 2)); console.error(`[classify-churn] ${JSON.stringify(counts)}`); diff --git a/tools/t3-codegen/generate.mjs b/tools/t3-codegen/generate.mjs index e94a65562..a392eeddc 100644 --- a/tools/t3-codegen/generate.mjs +++ b/tools/t3-codegen/generate.mjs @@ -31,6 +31,21 @@ * for three reasons: the pinned clone has no `node_modules` so `effect` would * not resolve from it; the staged copy is what gets hashed, so the hash covers * exactly what was read; and it keeps the tool from ever writing to the clone. + * + * WHICH CHECKOUT THIS GENERATES FROM (spec 250) + * --------------------------------------------- + * The FORK. `pin.commit` keeps its meaning — the commit the artifacts came from — + * and from phase 5 that commit lives only in the private customization checkout. + * Generating from the upstream clone while asserting `HEAD === pin.commit` would + * be unsatisfiable the moment the fork diverges, so the root moves with the + * commit rather than the assertion being loosened. + * + * `source-hash.json` therefore records TWO sections. `files` is the fork closure, + * as before, and `upstream` is the same closure hashed at `upstreamBase` from the + * upstream clone. Without the second section, "the generated artifacts match the + * source they were generated from" is a tautology: it compares the fork to + * itself. With it, the fork's divergence from upstream is a fact on disk that a + * test can read. */ import { createHash } from 'node:crypto'; @@ -39,6 +54,8 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { execFileSync } from 'node:child_process'; +import { resolveIdentities } from '../t3-fork/identities.mjs'; + const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); const outDir = join(repoRoot, 'packages', 'types', 'src', 't3', 'generated'); @@ -47,22 +64,26 @@ const stagingDir = join(here, '.staging'); const checkOnly = process.argv.includes('--check'); -/** Where the pinned t3code checkout lives. Overridable so CI can place it elsewhere. */ -const t3Root = process.env.T3CODE_ROOT ?? '/Users/chris/dev/t3code'; - function fail(message) { console.error(`[t3-codegen] ${message}`); process.exit(1); } -// ---------------------------------------------------------------- pin + checkout +// ---------------------------------------------------------------- pin + checkouts const pin = JSON.parse(readFileSync(pinPath, 'utf8')); +const { upstream, fork } = resolveIdentities(pin); + +/** + * The checkout generation reads. It is the FORK, because `pin.commit` is the fork + * head; `T3CODE_FORK_ROOT` overrides it so CI can place it elsewhere. + */ +const t3Root = fork.root; if (!existsSync(t3Root)) { fail( - `No t3code checkout at ${t3Root}.\n` + - `Set T3CODE_ROOT, or clone ${pin.repo} and check out ${pin.commit}.\n` + + `No fork checkout at ${t3Root}.\n` + + `Set ${fork.rootVar}, or clone ${fork.repo ?? 'the private fork'} and check out ${pin.commit}.\n` + `This is a HARD FAILURE, not a skip: generating from a checkout that is not there\n` + `would silently emit nothing and read as success.`, ); @@ -77,13 +98,32 @@ try { if (headSha !== pin.commit) { fail( - `Checkout is at ${headSha} but pin.json says ${pin.commit}.\n` + + `Fork checkout is at ${headSha} but pin.json says ${pin.commit}.\n` + `Generating against an unpinned tree would produce artifacts nobody can reproduce.\n` + `Either check out the pinned commit, or run the refresh procedure in REFRESH.md\n` + `to move the pin deliberately.`, ); } +/** + * The upstream clone, read only to hash the same closure at `upstreamBase`. + * + * Its absence does not fail generation: the fork is what the artifacts come from, + * and refusing to generate because a second, purely comparative checkout is + * missing would make a reference measurement a build dependency. It is recorded + * as `available: false` instead, which is spelled differently from "the hashes + * matched". + */ +const upstreamRoot = upstream.root; +let upstreamHead = null; +if (existsSync(upstreamRoot)) { + try { + upstreamHead = execFileSync('git', ['-C', upstreamRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + } catch { + upstreamHead = null; + } +} + // ---------------------------------------------------------------- stage the closure const contractsSrc = join(t3Root, pin.contractsRoot); @@ -125,6 +165,48 @@ for (const file of pin.closure.slice().sort()) { sourceHash.files[file] = createHash('sha256').update(bytes).digest('hex'); } +/** + * The upstream closure at `upstreamBase`, hashed from the upstream clone. + * + * `files` above is the fork, and a hash of the fork checked against artifacts + * generated from the fork proves only that the generator is deterministic. This + * section is the other end of the comparison: it says what upstream's bytes were + * at the base we branched from, so fork drift is a subtraction rather than a + * claim. `available: false` when the upstream clone is absent or its HEAD has + * moved off the base — an unmeasured section must not read as a measured match. + */ +sourceHash.upstream = { commit: upstream.commit, available: false, files: {} }; +if (upstreamHead === null) { + sourceHash.upstream.reason = `no readable upstream checkout at ${upstreamRoot}`; +} else if (upstreamHead !== upstream.commit) { + sourceHash.upstream.reason = + `${upstreamRoot} is at ${upstreamHead}, not upstreamBase ${upstream.commit}; ` + + 'hashing it would record the wrong tree under the right name'; +} else { + const upstreamContracts = join(upstreamRoot, pin.contractsRoot); + const missing = pin.closure.filter((file) => !existsSync(join(upstreamContracts, file))); + if (missing.length > 0) { + sourceHash.upstream.reason = `closure files absent from the upstream checkout: ${missing.join(', ')}`; + } else { + for (const file of pin.closure.slice().sort()) { + const bytes = readFileSync(join(upstreamContracts, file)); + sourceHash.upstream.files[file] = createHash('sha256').update(bytes).digest('hex'); + } + sourceHash.upstream.available = true; + } +} + +/** How many closure files the fork has actually changed. Zero until phase 5. */ +sourceHash.forkDrift = sourceHash.upstream.available + ? { + measured: true, + changedFiles: pin.closure + .slice() + .sort() + .filter((file) => sourceHash.files[file] !== sourceHash.upstream.files[file]), + } + : { measured: false, reason: sourceHash.upstream.reason }; + // ---------------------------------------------------------------- emit schemas const SR = await import('effect/SchemaRepresentation'); @@ -332,6 +414,8 @@ await scanForLoss(); // Orchestration methods come from the contract's own machine-readable map, so a // method added or renamed upstream shows up here rather than in a hand-edit. const rpcMap = orchestration.OrchestrationRpcSchemas; +/** Vendored modules a `pin.methods` entry may resolve its schema names from. */ +const MANUAL_METHOD_MODULES = { 'git.ts': git, 'orchestration.ts': orchestration }; for (const [method, spec] of Object.entries(pin.methods)) { if (method.startsWith('_')) continue; if (spec.source === 'OrchestrationRpcSchemas') { @@ -343,18 +427,44 @@ for (const [method, spec] of Object.entries(pin.methods)) { record(outName, entry.output); methods[method] = { input: inName, output: outName, stream: Boolean(spec.stream) }; } else { - const inSchema = spec.input ? git[spec.input] : null; - if (spec.input && !inSchema) fail(`pin.json names ${spec.input} for ${method}, but git.ts does not export it.`); + // Methods whose method STRING lives in the unvendored `rpc.ts` while their + // schemas live inside the closure. `vcs.*` were the first of these; spec + // 250's `codev.gateWrite` is the same situation and is recorded the same + // way. The module is NAMED by `source` rather than assumed to be `git.ts` — + // assuming it is what made the second case need a code change at all. + const mod = MANUAL_METHOD_MODULES[spec.source]; + if (!mod) { + fail( + `pin.json gives ${method} source "${spec.source}", which names no vendored module. ` + + `Expected "OrchestrationRpcSchemas" or one of: ${Object.keys(MANUAL_METHOD_MODULES).join(', ')}.`, + ); + } + const resolve = (name) => { + if (!name) return null; + const schema = mod[name]; + if (!schema) fail(`pin.json names ${name} for ${method}, but ${spec.source} does not export it.`); + return schema; + }; + const inSchema = resolve(spec.input); if (inSchema) record(spec.input, inSchema); - const outSchema = spec.output ? git[spec.output] : null; - if (spec.output && !outSchema) fail(`pin.json names ${spec.output} for ${method}, but git.ts does not export it.`); + const outSchema = resolve(spec.output); if (outSchema) record(spec.output, outSchema); - methods[method] = { input: spec.input ?? null, output: spec.output ?? null, stream: false }; + methods[method] = { input: spec.input ?? null, output: spec.output ?? null, stream: Boolean(spec.stream) }; } } -// The event union is not an RPC payload but every consumer decodes it. -for (const name of ['OrchestrationEvent', 'ClientOrchestrationCommand']) { +/** + * Schemas that are not RPC payloads and are decoded by every consumer anyway. + * + * `OrchestrationDispatchRefusal` joined the list in spec 250 phase 6. It is an + * ERROR shape rather than a payload, and the generator otherwise emits neither — + * but this one travels on `OrchestrationDispatchCommandError.refusal` and carries + * the reason vocabulary a client switches on. Leaving it out meant `porch-driver` + * kept its own copy of six string literals and checked it by hand against a file + * in a checkout it does not import, which is the arrangement this whole vendoring + * exists to replace. + */ +for (const name of ['OrchestrationEvent', 'ClientOrchestrationCommand', 'OrchestrationDispatchRefusal']) { if (orchestration[name]) record(name, orchestration[name]); } @@ -446,9 +556,24 @@ function tsTypeFor(node, indent = 0, seen = new Set()) { } } -const dtsLines = [ +/** + * The provenance banner, written ONCE. + * + * `pin.commit` is a fork commit and exists only in `pin.forkRepo`; naming + * `pin.repo` beside it points a reader at a repository that has never held it. + * There were three emitted headers making this claim and the third was a separate + * hand-written string, so correcting two of them left the one that ships wrong — + * caught in review. A list that must not drift does not need a better guard on the + * copies, it needs to stop having copies. + */ +const PROVENANCE = [ '// GENERATED by tools/t3-codegen — do not edit.', - `// Source: ${pin.repo} @ ${pin.commit}`, + `// Generated from: ${pin.forkRepo} @ ${pin.commit} (a private modified copy)`, + `// Which branched from: ${pin.repo} @ ${pin.upstreamBase}`, +]; + +const dtsLines = [ + ...PROVENANCE, '//', '// Derived from the emitted JSON Schema, not from the Effect source, so these', '// declarations reference no runtime library and `packages/types` keeps zero', @@ -482,16 +607,23 @@ const attribution = `# Attribution The files in this directory are **generated from t3code**, which is MIT licensed. -- Source: ${pin.repo} -- Commit: \`${pin.commit}\` (${pin.commitDate}) +They are generated from a **private modified copy** of t3code, not from the upstream +repository, so both are named. \`${pin.commit}\` exists only in the fork; looking for it in +${pin.repo} would not find it, and an attribution that named upstream alone would be +pointing at a commit that is not the source of these files. + +- Generated from: ${pin.forkRepo} — commit \`${pin.commit}\` (${pin.commitDate}), branch \`${pin.forkBranch}\` +- Which branched from: ${pin.repo} — commit \`${pin.upstreamBase}\` (${pin.upstreamBaseDate}) - Generated by: \`tools/t3-codegen/generate.mjs\` +- Modifications: see \`tools/t3-fork/FORK.md\` and the exported patches in \`tools/t3-fork/patches/\` \`@cluesmith/codev-types\` is published under Apache-2.0 and ships \`files: ["src", "dist"]\`, so these derived artifacts leave this repository inside a distributed package. MIT requires its notice to travel with the distribution, which is why this file sits beside them rather than in a place a packaging step might drop. -The notice below is reproduced verbatim from \`LICENSE\` at the pinned commit. +The notice below is reproduced verbatim from \`LICENSE\` at the pinned commit. The fork carries +it unmodified from upstream. \`\`\` ${upstreamLicense} @@ -568,8 +700,7 @@ ${unrepresented.length === 0 ? `_None of the ${Object.keys(schemas).length} cons // JSON into dist" build step, which is the kind of thing that passes in CI and // then fails at a consumer's runtime because the file never reached `dist`. const schemaModule = - '// GENERATED by tools/t3-codegen — do not edit.\n' + - `// Source: ${pin.repo} @ ${pin.commit}\n` + + PROVENANCE.join('\n') + '\n' + '//\n' + '// A LOWER BOUND on t3code\'s validation, not an equivalent. See LOSSY.md.\n\n' + 'export const t3Defs = ' + diff --git a/tools/t3-codegen/transform-blindness-probe.mjs b/tools/t3-codegen/transform-blindness-probe.mjs index db36c432d..7dd761bd8 100644 --- a/tools/t3-codegen/transform-blindness-probe.mjs +++ b/tools/t3-codegen/transform-blindness-probe.mjs @@ -23,10 +23,18 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveIdentities } from '../t3-fork/identities.mjs'; + const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, '..', '..'); const pin = JSON.parse(readFileSync(join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'), 'utf8')); -const t3Root = process.env.T3CODE_ROOT ?? '/Users/chris/dev/t3code'; + +// Spec 250: the FORK identity. This probe asks whether the drift layers would +// catch a relaxed check in the source WE GENERATE FROM, and from phase 5 that +// source is the fork. Probing upstream would measure a tree the artifacts no +// longer come from, and report the answer as if it were about ours. +const { fork } = resolveIdentities(pin); +const t3Root = fork.root; const basePath = join(t3Root, pin.contractsRoot, 'baseSchemas.ts'); const SR = await import('effect/SchemaRepresentation'); diff --git a/tools/t3-fork/FORK.md b/tools/t3-fork/FORK.md new file mode 100644 index 000000000..b90341b03 --- /dev/null +++ b/tools/t3-fork/FORK.md @@ -0,0 +1,576 @@ +# The private t3code fork + +Spec 250. `t3code` is the front end; Codev integrates with it. Every change we make to +t3code is a **private customization** — it does not go upstream to `pingdotgg/t3code`, and +we do not ask for their buy-in. + +## The two checkouts + +| | Upstream | Fork | +|---|---|---| +| Repository | `https://github.com/pingdotgg/t3code` (public) | `https://github.com/pseudoseed/t3code` (**private**) | +| Branch | `main`, read-only | `codev` | +| Checkout | `/Users/chris/dev/t3code` | `/Users/chris/dev/t3code-codev` | +| Env override | `T3CODE_ROOT` | `T3CODE_FORK_ROOT` | +| Pinned to | `pin.upstreamBase` | `pin.commit` | +| Written to by us | **never** | yes, one commit per plan phase | + +The fork's remotes: `origin` is the private repository, `upstream` is `pingdotgg/t3code`. + +## Why it is a created repository and not a GitHub fork + +`gh repo fork` was not used and must not be. **A GitHub fork inherits the visibility of the +repository it forks**, so forking a public repository cannot produce a private one. The +repository was created with: + +```bash +gh repo create pseudoseed/t3code --private +``` + +and the history was pushed into it. `gh repo view pseudoseed/t3code --json visibility` reports +`PRIVATE` and `isFork: false`; that is asserted rather than inferred from the create command +having exited zero. + +The MIT `LICENSE` and its attribution travel with the copy, unmodified. + +## Why the upstream clone must never move + +Every piece of spec 146 and spec 236 evidence — the cold-start runs, the recorded source +hashes, the live contract suite — reproduces against `/Users/chris/dev/t3code` at +`upstreamBase`. Moving it off that commit does not break a test; it makes recorded results +unreproducible while every test still passes. + +That is why `t3-server.mjs`'s `acquire`, `start` and `status` are pinned to `upstreamBase` +rather than to `pin.commit`. `acquire()` runs `git checkout --detach` against the upstream +clone, and both `smoke.mjs` and `packages/t3-client/live/integration.mjs` call it, so an +ordinary test run would have written a fork sha into the read-only clone the moment +`pin.commit` diverged. + +## Which tool reads which checkout + +| Reader | Identity | Why | +|---|---|---| +| `tools/t3-server/t3-server.mjs` | both | it is the verifier; `verify` asserts each, other verbs are upstream-only | +| `tools/t3-codegen/generate.mjs` | fork | generation is fork-sourced from phase 5; also hashes upstream for comparison | +| `tools/t3-codegen/classify-churn.mjs` | both | one identity per mode, and the mode is mandatory | +| `tools/t3-codegen/transform-blindness-probe.mjs` | fork | it probes what we emit | +| `tools/t3-server/smoke.mjs` | upstream | keeps the spec 146 cold-start evidence reproducible | +| `packages/t3-client/live/integration.mjs` | upstream | spec 146 / #241 live tests, meaning unchanged | +| `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` | both | upstream suite asserts upstream, fork suite asserts fork | + +The mapping is resolved in one place, `tools/t3-fork/identities.mjs`. The one deliberate +exception is `packages/t3-client/live/integration.mjs`, which reads `T3CODE_ROOT` directly and +**requires** it (#214): a missing input there must read as a sentence rather than as a failure +inside the server, and keeping it required also means the fork's path cannot arrive by accident. +It never reads `T3CODE_FORK_ROOT`, and a test asserts that. + +## Verifying + +```bash +node tools/t3-server/t3-server.mjs verify # both identities +node tools/t3-server/t3-server.mjs verify-upstream # upstream only +node tools/t3-server/t3-server.mjs verify-fork # fork only +``` + +The per-identity verbs exist so an upstream-only caller does not acquire a dependency on the +fork. `smoke.mjs` and `packages/t3-client/live/integration.mjs` use `verify-upstream`, and so +does `ready` — a fork that has moved ahead of `pin.commit` says nothing about the upstream +process answering on the port, and gating an upstream server start on it would break every +spec 146 run the moment we commit a customization. + +Exit `0` with both checkouts clean on their pins. Exit `1` names which identity failed. Exit +`3` is "could not determine" — a missing checkout, an unreadable HEAD, an unresolvable +merge-base — and it is never spelled the same way as `1`. + +### Ahead of the contract is not the same as on the wrong commit + +`pin.commit` means **the vendored contract was generated from this commit**, and only +regeneration moves it. Phase 5 is where regeneration happens, so from the fork's first +customization commit until then the checkout is legitimately ahead of the pin. `pin.contractSource` +records which state we are in: + +| `contractSource` | Fork HEAD descends from `pin.commit` | Fork HEAD does not descend | +|---|---|---| +| `upstream` (phases 1-4) | `FORK_AHEAD_OF_CONTRACT`, exit `0` | `FORK_CHECKOUT_MISMATCH`, exit `1` | +| `fork` (phase 5 onward) | `FORK_AHEAD_OF_CONTRACT`, exit `1` | `FORK_CHECKOUT_MISMATCH`, exit `1` | + +**`contractSource` is now `fork`.** Phase 5 regenerated, so the tolerated row is history: a +checkout ahead of `pin.commit` means the vendored contract is stale and `verify` exits `1`. The +next customization commit therefore turns the suite red until the contract is regenerated from it, +which is the intended cost of vendoring a moving source. + +The tolerated case was reported, not silenced — it printed on every run. It was spelled +differently from a real error on purpose: a signal that fires for three phases straight is one +people learn to ignore, and then it fires for a real reason and nobody looks. + +A contract commit the fork repository does not contain at all is `NO_FORK_ANCESTRY`, exit `3`. +Whether HEAD descends from a commit that is not there is not a question git can answer. + +`verify` also asserts `git merge-base == `. A rebase or +a squash that drops the base leaves a fork that is clean at a commit nothing can be measured +against, and without that check it verifies green. + +## The rebase surface: which UPSTREAM files this fork edits + +Phase 11 is the rebase drill, so this is the list it drills against. As of `e0476d49aec1`: +**35 upstream files MODIFIED, 35 files ADDED.** Only the modified ones can conflict, and the split +is measured rather than remembered — a count that drifts is worse than no count: + +```bash +git diff --name-status "$upstreamBase"..HEAD -- . ':(exclude)docs/codev' \ + | awk '$1=="M"{print $2}' # the conflict surface +git diff --name-status "$upstreamBase"..HEAD -- . ':(exclude)docs/codev' \ + | awk '$1=="A"{print $2}' # carried without conflict +``` + +The added half is `apps/server/src/codev/`, `apps/web/src/codev/`, +`packages/shared/src/codevAgentProxy.ts`, `apps/web/src/routes/_chat.codev-builders.tsx` and +`apps/server/scripts/apply-codev-guard.ts`. **Roughly half the modified files are upstream TESTS** +(`decider.delete.test.ts`, `projector.test.ts`, `ProjectionSnapshotQuery.test.ts`, +`commandInvariants.test.ts`, `server.test.ts`, `orchestration.test.ts`, `Sidebar.logic.test.ts`, +`AgentAwarenessRelay.test.ts`, two `serverRuntimeStartup` tests), which conflict as readily as +source and are the half easiest to forget when estimating the drill. + +**Measured on 2026-08-31 by `tools/t3-fork/rebase-drill.mjs`** against upstream `9b2d04317c68`, +104 commits past our base: **3 of the 35 modified files conflict.** The risk column below is the +prediction; the drill is the measurement, and where they disagree the drill wins: + +| File | Predicted | Measured | +|---|---|---| +| `packages/contracts/src/orchestration.ts` | **High** | **auto-merged clean** — and upstream touched it twice in exactly the two unions we extend (`subscribeThread`, `dispatchCommand`) | +| `apps/server/src/server.test.ts` | Low ("mostly one-hunk additions") | **conflicts**, and it is where the sequential rebase stops, at commit 6 | +| `apps/web/src/components/Sidebar.tsx`, `Sidebar.logic.ts` | Medium | **conflicts** | +| the pinned contract closure | — | **zero conflicts**, so regeneration is not blocked, and **4 of the 9 closure files come out of the merge with different bytes** (`auth.ts`, `baseSchemas.ts`, `environment.ts`, `orchestration.ts`) | +| the regenerated contract | — | **the generator runs to completion** against the merged tree, and `schema.json`, `schema.ts` and `types.d.ts` all move. That is the cost of adopting this base, run rather than predicted, in a throwaway that leaves `pin.json` alone | + +The prediction was wrong in the direction that matters least (a High that came out clean) and right +about the sidebar. What it under-rated was the upstream TEST — which is the half this table already +warned is easiest to forget, now demonstrated rather than asserted. + +| Where | Files | What we change | Conflict risk | +|---|---|---|---| +| `packages/contracts/` | `orchestration.ts`, `orchestration.test.ts`, `auth.ts`, `rpc.ts` | `role`, `parentThreadId`, `codevGate`, `gateRevision`, the `codev.gateWrite` method and its scope | **High.** `orchestration.ts` is the file upstream changes most, and every one of our fields sits in structs it edits. | +| `apps/server/src/orchestration/` | `decider.ts`, `projector.ts`, `commandInvariants.ts`, `Errors.ts`, `Layers/OrchestrationEngine.ts`, `Layers/ProjectionPipeline.ts`, `Layers/ProjectionSnapshotQuery.ts`, `Services/OrchestrationEngine.ts` + 4 test files | hierarchy refusal at write time, the gate write, the refusal reason surviving the ws boundary, committed events returned | **Medium-high.** Eight source files across the command path. | +| `apps/server/src/persistence/` | `Layers/ProjectionThreads.ts`, `Services/ProjectionThreads.ts`, `Layers/Sqlite.ts` | the two columns on both persistence paths; one `codevSchemaGuardStep` call in `setup`, after `runMigrations()` | **Low-medium.** The Sqlite hunk is one line. | +| `apps/web/src/components/` | `Sidebar.tsx`, `Sidebar.logic.ts`, `Sidebar.logic.test.ts`, `ChatView.tsx` | the Workspace → Architect → Builders tree; `` above the composer; one extra condition on `hideEmptyPlaceholder` | **Medium** for the sidebar, **low** for `ChatView` (two small hunks). | +| server plumbing | `http.ts`, `server.ts`, `ws.ts`, `auth/RpcAuthorization.ts`, `serverRuntimeStartup.ts` (+ 2 tests), `server.test.ts`, `relay/AgentAwarenessRelay.test.ts`, `auth/CodevGateScope.test.ts` | `export` on `authenticateRawRouteWithScope`; three codev route layers merged into `makeRoutesLayer`; gate-writer provisioning; the `codev:gate-write` scope | **Low.** Mostly one-hunk additions. | +| generated / manifest | `routeTree.gen.ts`, `packages/shared/package.json` | the `_chat/codev-builders` route (regenerated, not hand-edited); one `exports` entry for `./codevAgentProxy` | **Low.** `routeTree.gen.ts` regenerates. | + +**`apps/client` is untouched, and stays untouched.** It is the frozen fallback: +`git diff ..HEAD -- apps/client` is empty at every phase boundary from 7 onward, +and that is checked rather than assumed. + +## Do not "tidy" these on rebase + +Two things in this fork look like inconsistencies and are not. Both are cheap to "fix" and both +fixes are wrong. + +**1. `role` / `parentThreadId` are spelled two different ways.** + +| Schema | Spelling | Why | +|---|---|---| +| `ThreadCreatedPayload` | `Schema.NullOr(...).pipe(Schema.withDecodingDefault(...))` | The event log holds `thread.created` payloads written before the fields existed. A rebuild replays every one and the projector reads `payload.role` unconditionally, so that read must be total. | +| `OrchestrationThread`, `OrchestrationThreadShell` | `Schema.optional(Schema.NullOr(...))` | Matches `linkedPullRequest` one line above — upstream's own newest field, optional so older cached snapshots decode. | + +Unifying on the strict form produces **32 errors across 11 upstream test files**, paid again at +every rebase, to remove `undefined` from a read model the server never emits as `undefined` (every +read path normalizes `?? null`). The strict form looks more correct in isolation, and the reason it +is worse here is not visible from the diff. Endorsed by the architect on 2026-08-30. + +**2. The schema guard is not wired to `MigrationsLive`.** + +`MigrationsLive` is exported from `persistence/Migrations.ts` and **nothing builds it**. Wiring the +guard there would look tidier and would mean it never runs in production — while a test that +constructs `MigrationsLive` itself passes. It is called from `persistence/Layers/Sqlite.ts`'s +`setup`, which is the path that actually boots the database, and a test asserts the ordering +against that file rather than against a layer it assembles. + +## The exported patches are a review aid + +`tools/t3-fork/patches/` holds `git format-patch upstreamBase..pin.commit`, one file per fork +commit. **It exists to be read.** A reviewer who does not have the private repository can see +every byte of the customization in the Codev pull request. + +**It is not how the fork is built, and not how it is rebased.** The fork is a git repository with +real history and a real `upstream` remote; it moves forward with `git rebase upstream/main` or a +merge, against the actual commits. Nothing in this project applies these patches to produce the +fork, and a patch that fails to apply says nothing about whether the fork is healthy. + +They are regenerated whenever `pin.commit` moves: + +```bash +rm -f tools/t3-fork/patches/*.patch +git -C "$T3CODE_FORK_ROOT" format-patch --no-signature \ + -o "$(pwd)/tools/t3-fork/patches" .. \ + -- . ':(exclude)docs/codev' +``` + +`--no-signature` matters: without it every patch footer carries the local git version, so the +files churn whenever someone regenerates them on a different machine. + +**`-o` takes an ABSOLUTE path.** It resolves relative to `-C`, not to your shell, so a relative +one writes the patches into the fork checkout — where they are untracked litter that makes +`start-fork` refuse the next run. + +**`docs/codev` is excluded, and that is what the artefact is FOR.** That directory holds the UI +screenshots, and a screenshot in a patch is a base64 blob: unreadable by the human this file +exists to serve, and rewritten in full every time a phase re-shoots. Four screenshot commits had +taken the export from 504KB to 14MB, which made the diff noisier the more carefully the UI was +photographed. The pictures live in the fork and are read there. + +**So the patch numbering has gaps, and the phase log above is the complete list.** A commit that +touches only `docs/codev` produces no patch at all. Every fork commit is listed in the phase log +with its sha whether or not it exported a patch, and that table — not the file count — is the +answer to "what is in this fork". + +## Abandoning the fork + +The spec keeps `apps/client` as the fallback, so falling back has to be a procedure rather than a +reconstruction. Four steps: + +1. Set `pin.commit` back to `pin.upstreamBase` in `packages/types/src/t3/pin.json`. +2. Set `pin.contractSource` back to `"upstream"`. +3. Regenerate: `node tools/t3-codegen/generate.mjs` (Node 22, with the upstream clone present). +4. Re-run `node tools/t3-server/t3-server.mjs verify`. + +Remove `codev.gateWrite` from `pin.methods` in the same edit — its schemas live only in the fork, +so the generator fails on step 3 if it is left behind. That failure is the procedure working, not +a problem with it. The fork repository and `tools/t3-fork/patches/` can stay where they are; the +vendored contract is what decides whether Codev depends on the customization. + +## Phase log + +| Phase | Fork commit | What landed | +|---|---|---| +| 1 | `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6` | Branch `codev` created at `upstreamBase`. No customization yet — the two identities exist and are equal on purpose, so every new assertion has a known answer. | +| 2 | `1a414cee8409a407977ff6c6505fad1ab82f2ec8` | `role` and `parentThreadId` on the thread record, through the contract, the projector and both persistence paths. Columns applied by `apps/server/src/codev/schemaGuard.ts`, outside upstream's migration registry. | +| 2 | `992b781f4314ec1df1abb752c7c9c5378ec13c26` | Review fixes: the "upstream migration still runs" test goes through the migrator instead of a raw `ALTER TABLE`, and `apps/server/scripts/apply-codev-guard.ts` runs the real guard against a file-backed database for criterion 8b. | +| 2 | `e1a858434a8096d7a82e05347f8159d94f42c0b1` | The two `CODEV_SCHEMA_GUARD_*` log signals are pinned by a test — they are the whole mitigation for staying out of the migration registry and nothing enforced they stay two. | +| 3 | `e1b7f7b04af5aa869a552baa622fc9e526a00bb3` | Illegal hierarchy edges refused at write time in the decider, with six reason discriminants. `role`/`parentThreadId` added to `thread.create`, optional so upstream clients dispatch it unchanged. | +| 3 | `40fb82ce92a8ed42e6868bd946bfee00b79b3022` | `OrchestrationEngine` was rewriting every refusal as "Failed to generate an event identifier" and persisting it onto the rejected receipt. The discriminants now survive the wrapper, asserted by an engine-level test. | +| 4 | `3a1780bbf66f` | Gate block, `gateRevision` high-water mark, `codev.gateWrite` on its own RPC method with its own scope, and the engine returning its committed events. | +| 4 | `57d24ddcb3be` | 29 tests: revision monotonicity and criterion 10, the scope exclusions, the payload bounds. | +| 4 | `6e8bdec207d6` | The two tests the optional-on-the-wire deviation rests on: the column rejects NULL, and an absent `gateRevision` decodes to a number. | +| 4 | `3d0e76776cd9` | `isRefusal` was deleting gate refusals — the phase 3 bug in the same function. Reason taxonomy split, projector gate coverage added, and the single gate-write credential named. | +| 4 | `570cc29dc63c` | `dispatchErrorKind` makes an unclassified dispatch error a **compile error**, replacing the hand-written disjunction that shipped the same bug three times. | +| 4 | `0254c84e1241` | The gate-writer credential is actually provisioned at server start. It had no production caller — costume one, in the phase that named it. | +| 4 | `51b55d4899e4` | `OrchestrationRefusal` derived from the `DISPATCH_ERROR_KIND` table instead of hand-listing the same three tags a second time. The classification is one place; a missing member is a missing key. | +| 6 | `804e56f8f864` | A refusal's `reason` did not survive the ws boundary — measured against a live fork server, not inferred. `OrchestrationDispatchCommandError` gains an optional `refusal` field; `CodevHierarchyInvalidReason` moves into the contract because it travels; four wrapping sites lift or forward it. | +| 7 | `4633e0a7f498` | `apps/web/src/codev/hierarchy.ts` — the grouping, as a pure function over the two fields phase 2 added. Three buckets: architect subtrees, roleless threads kept flat, and orphans named with the reason they could not be placed. | +| 7 | `90a5a2d3a312` | The call site: the sidebar's active list is ordered by the grouping and the tree is drawn from that order. `alsoVisible` added so a builder whose architect is pinned reads as `parent-elsewhere`, not `parent-missing`. | +| 7 | `e19e2560dd7a` | The tree screenshotted at 390, 1440x900 and 1920, committed under `docs/codev/`. | +| 7 | `a183f56ecec2` | The project level, the architect's role marker, and Settled's treatment for the orphan group in place of amber — the architect's review of the first screenshots. | +| 7 | `48a9aa399e5d` | The three widths re-shot against those changes. | +| 7 | `7c7096d49de9` | Review fix: the builder count came from the render scan the test counts, so it could only agree with itself. Sourced from the grouping. | +| 8 | `5e8ace3b186f` | `apps/web/src/codev/gateState.ts` and `GatePanel.tsx` — the porch gate read from `codevGate`, in three states, with a sidebar marker in a hue no existing status pill owns. | +| 8 | `39204a7ac368` | The gate panel screenshotted at the three widths; phase 7's re-shot in the same commit, because two builders now carry gates. | +| 8 | `90d2b118b786` | The terminal excerpt gains a caption — unlabelled it is just trailing monospace. | +| 8 | `81c2463d7…` → `98e950e42` | The row marker: two placements tried, both clipped something at ~230px. It is a gavel plus the gate name now, on the line above the title. | +| 8 | `efadf838c414` | Both phases re-shot against those changes. | +| 9 | `36038cdcb…` → `d2e675a7aa08` | `apps/web/src/codev/layout.ts`, `BuilderGrid.tsx`, `BuilderPane.tsx` and a `_chat/codev-builders` route — four to six builders watchable at once, geometry ported from `apps/client` and re-measured against t3code's chrome. | +| 9 | `36717ab7ecfc` | The route gains a header: at 390 the shell's floating sidebar toggle was sitting on the first pane's title. | +| 9 | `2529a40421d1` | The grid screenshotted at the three widths. | +| 9 | `6fecade36146` | Criterion 4b: the architect takes a strip below the grid rather than a ragged seventh tile, and an equal tile only where four columns fit. | +| 9 | `b97ef30dea2b` | Re-shot with the strip. | +| 9 | `0065abc29ed7` | The pane's role prefix cannot be clipped: it is the only thing distinguishing an architect tile from a builder tile when every architect takes one. | +| 9 | `aeebd7f2b9c2` | Re-shot with a long title in the grid, which is what makes the prefix test able to fail. | +| 9 | `8d4b878f3137` | Re-shot after the 3-way review fixes: a sidebar entry point to the grid, and one width measurement instead of two. | +| 10 | `0b90c36682a4` | `apps/server/src/codev/agentProxy.ts` — the same-origin proxy to `codev-agent`, with a server-configured origin allowlist. Web side: `pairing.ts`, `approval.ts`, `agentState.ts`, `useCodevAgent.ts`, `PairingPanel.tsx`, `GateApproval.tsx`, and the panes reading the porch phase and messages `codev-agent` had been publishing since phase 6. `packages/shared/src/codevAgentProxy.ts` holds the two paths both sides must agree on. | +| 10 | `79db4c7b8f07` | The page read the agent store once and froze. The hand-rolled `useState` tick never followed the store; `useSyncExternalStore` does. Found by the browser, invisible to every unit test. | +| 10 | `75150bfcf382` | A gated pane dropped the phase it had just gained: the gate replaced the phase line rather than leading it. | +| 10 | `fe10e0c0b07f` | `Send a message to start the conversation.` printed across `Waiting on you: ` on a thread with no turns. Present since phase 8; the panel-only screenshots did not show it. Hidden through upstream's own `hideEmptyPlaceholder`, because it is also wrong advice at a gate. | +| 10 | `e0476d49aec1` | The pairing form, the approve control and the grid screenshotted at the three widths. | +| 10 | `24aeeebb3` | The proxy buffered request bodies with no bound. `MAX_PROXIED_BODY_BYTES` at 64 KiB, with "too large" and "malformed" given different signals — a chunked body declares no length, so the cap on the read is what answers for it. | +| 10 | `3786b840e` | 3-way review fixes: `UPSTREAM_TIMEOUT_MS` claimed more than an idle timeout gives, and `data-codev-approval-state` was coarser than its own words. | +| review | `2f64a1b0e` | The codex lane's two blocking findings. `send` in `approval.ts` returns transport failure as a **value**, so all five call sites must answer for a dead network — three pre-submit steps report a definite `AGENT_UNREACHABLE_*` because nothing was submitted, and both submit routes report `unconfirmed` because the request may have arrived. `GateApproval` gains the `catch` its `finally` never had. And `MAX_PROXIED_RESPONSE_BYTES` bounds the **return** path, which `24aeeebb3` had left unbounded on the same file. | + +### Both bounds, and why the return path was the worse one + +`24aeeebb3` bounded the request body during phase 10. The **response** stayed unbounded until the +codex lane's review of the PR, and it is the more exposed half of the same defect. + +A request body arrives from an authenticated, paired caller, so the cap bounds what a browser +somebody let in can make this server hold. A response body arrives from whatever +`CODEV_AGENT_ORIGINS` names — so an operator misconfiguration, or a `codev-agent` that streams +without end, made the server buffer without end, and no credential was needed to arrange it. + +Two numbers rather than one, deliberately: 64 KiB for requests, which are a few hundred bytes of +JSON, and 1 MiB for responses, which carry an operation record with its check names and pane +content. Tying them together would make one of the two wrong the first time either kind of traffic +changed. + +**`oversized` is its own outcome**, alongside `unreachable` and `silent`. Passing on the first +megabyte as though it were the whole reply is a partial answer reading as a complete one, on the +route that decides whether a gate was approved; and reporting it as `unreachable` sends an operator +to check whether a host that is plainly running and answering is running. + +The first version settled *after* `destroy()`, and `destroy()` makes the stream emit `error` +synchronously — so the `error` handler's `unreachable` won the race and the proxy reported a +reachable host as unreachable. `settle` is once-only, which is exactly why the truthful outcome has +to be claimed first and the teardown done second. + +### A careful outcome vocabulary is not the same as answering + +`approval.ts` documents four outcomes in its header, spells `unconfirmed` apart from refusal in +five places, and refuses to invent `approvedAt` from the browser clock. Eleven rounds of review +read all of that approvingly. + +None of it ran when `fetch` itself rejected. `send` did a bare `await fetchImpl(...)`, four of its +five call sites had no `catch`, and `GateApproval` had a `finally` and no `catch` — so a proxy +disconnect while opening the session, issuing the capability, minting the nonce, or taking the +synchronous fallback stopped the spinner and produced **nothing**. No error, no unconfirmed state, +no outcome. On the approval surface that is the worst answer available, because it is +indistinguishable from having pressed nothing. + +The fix is a type rather than a `try`, so the next call site cannot inherit it: `send` returns +`({reached: true} & Json) | {reached: false, error}`, and `reached: false` is not assignable to +anything that reads `.status`. + +**The three pre-submit steps are NOT `unconfirmed`, and that distinction is the point.** Nothing +was submitted, so the gate provably did not move; saying "check the gate" there would teach a human +that `unconfirmed` is the ordinary noise of a flaky network, which is precisely how the rare real +one gets ignored. The two submit routes — the async one, and the synchronous fallback that approves +before it answers — are `unconfirmed`, because the request may have arrived and only the reply been +lost. + +### The proxy's upstream is the OPERATOR's, and the browser cannot name one + +Phase 10 gives the fork's server a reverse proxy to `codev-agent` at +`/api/codev/agent//`, so t3code's page reaches the approval +ceremony without ever making a cross-origin request. There is no page-level CSP in t3code to +lean on — `Content-Security-Policy` appears on `.svg` asset responses only — so the guarantee +is structural: the page holds no absolute URL, and the e2e watches the network rather than +parsing a header that is not sent. + +**Configure it with `T3CODE_CODEV_AGENT_ORIGINS`,** a comma-separated list of `id=origin` +entries, e.g. `local=http://127.0.0.1:4100`. Unset means the proxy carries nothing and SAYS SO; +an entry that cannot be used is reported rather than dropped. The browser selects a target by +**id**, never by URL — a proxy that forwards to an origin the browser names is an SSRF +primitive, and a route-path allowlist does not constrain the host. + +Three things it deliberately does not do: it does not carry the SSE stream (it buffers, and a +buffered stream is live on the wire and empty in the page); it does not carry either revocation +route (`afx pair revoke` is the operator path, and a browser that could revoke could deny a +human their own gate); and it does not forward `authorization` or `cookie`, because t3code's own +session is not approval authority and no other server should be handed t3code's identity. + +**Phase 11 added no row either, and for a different reason.** It is the acceptance phase: the +rebase drill, the churn report, the watermark re-check and the evidence all live in the Codev +repository and read the fork without writing to it. The one change it made outside this repo was to +`apps/client`'s test suite — which is Codev's, not the fork's. **The drill deliberately leaves no +trace in the fork**: it works in a throwaway clone and asserts afterwards that the fork head did not +move. + +**Phase 5 added no row, and that is not an omission.** It regenerated the vendored contract in the +Codev repository from `51b55d4899e4`; it changed nothing in the fork. + +**Phase 6 added one, and it was found by running the thing.** The plan's acceptance criterion for +phase 6 is a live round trip: dispatch an illegal hierarchy edge over a socket and assert the client +can still tell "no such parent" from "wrong parent role". Doing that needs a server built from THIS +source, which `t3-server.mjs start` does not provide — it runs the published `t3@` +CLI against the upstream checkout, and that server has no `codev.*` anything. So the harness gained +`start-fork`, which runs `apps/server/src/bin.ts` directly, on its own port and its own runtime +directory, sharing no state with the upstream server. + +The first run of that test failed, and the failure was the point: every refusal arrived as +`OrchestrationDispatchCommandError` with the reason inside `message`, as English. Phase 3 fixed the +ENGINE deleting discriminants; the ws layer was flattening them one hop further out, and every test +beneath that hop was green. + +**Phase 7 is the first phase that renders, and its finding came from the compiler.** + +`hierarchy.ts` is pure, its tests build their own row type, and both of those are right for testing +a grouping — and together they cannot tell you the module fits anything the sidebar holds. Two +assignments from `SidebarThreadSummary` and `Thread` at the top of the test file are the check, and +they failed before any call site existed: + +- the module keyed on `threadId`, the **command** spelling, while both read models call it `id`; +- `role?: X` does not accept `undefined` under `exactOptionalPropertyTypes`, so the interface + described a shape no caller has until `| undefined` was written out. + +Neither is a runtime error. Both would have surfaced as `buildCodevHierarchy(threads)` quietly +returning no hierarchy — which on screen reads as an empty workspace, not as a bug. + +**The section boundary is the fork's, and the renderer had to learn it.** t3code splits a project +into Pinned / Active / Snoozed / Settled before any grouping runs, so the tree is built over ONE of +those lists. A builder whose architect the user pinned is then looking at a list its parent is not +in, and the first draft answered `parent-missing` — three rows below the architect the user can see. +`buildCodevHierarchy` now takes `alsoVisible`, the rest of the sidebar, and answers +`parent-elsewhere` instead. Role still outranks section: a non-architect parent stays +`parent-not-architect` wherever it sits, because a section boundary must not change what a thread is. + +**Nothing changes for a project with no Codev roles.** `hasCodevHierarchy` is false there and the +renderer takes the loop it has always had — same rows, same order, no wrappers, no headings. An +empty tree's chrome would be new furniture in every upstream user's sidebar for a feature they do +not have. + +**The order returned is also the ordered list.** `orderedThreads` is not only a render order: +shift-range-select and jump-hint labels are assigned from it. A component that reordered rows while +leaving that list alone would draw a correct tree whose keyboard reached the wrong rows — every row +in the right place and nothing on screen to show it. + +**Three changes came from the architect's review of the first screenshots, and one of them was a +criterion gap rather than taste.** Criterion 1 is three levels — project, architect, that +architect's builders — and the render had two: the project was present only as a caption repeated +on all eight cards, one string in the most prominent line of every row, with the thread's own name +below it in lighter weight. It is a heading now, once, carrying the project's own favicon; rows +under it drop the per-row label and rows outside it keep it, where it is the only thing saying +which project they belong to. Architect subtrees are gathered by project so a project's run is +contiguous, because a heading over a run another project interrupts is a heading that lies. + +The other two: nothing said which row was an architect — it was carried by one level of subtle +indent plus test data that happened to be called "Architect beta", and real threads are called +`builder/spir-250` — so the architect row is captioned in the slot the project label vacated, and +builders are not, because a caption on every child of a labelled parent is a caption nobody reads. +And the orphan group was amber, which says something is broken; an archived architect orphaning its +builders is a state this project ruled LEGAL, so it wears Settled's treatment with the emphasis on +the count. + +Verified in a browser against the fork's own web app, not only in unit tests: +`packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts` in the Codev repository, run with +`npx playwright test --config playwright.spec250.config.ts`. The fork's Vite dev server must be +running; an absent one is reported as a skip carrying the command to start it, never as a pass. + +### Phase 8: the gate has to be written by the credential that writes gates + +The e2e fixture could not seed a gate the way it seeds everything else. A bootstrap exchange +requesting `codev:gate-write` is refused with `invalid_scope`, which is phase 4's design holding: +gate writes come from ONE credential — `codev-agent`, scoped to `orchestration:read` and +`codev:gate-write` and nothing else — provisioned by the server rather than derived from whatever +token a client happens to hold. + +So the fixture reads that credential from `/codev/gate-writer.token`, where the +fork's server writes it at start, and opens its own connection with it — which is exactly what +`thread-backend.ts` does in production. A fixture that obtained the ability any other way would +have been testing a path no writer uses. + +### Phase 9: the tiling had to be re-measured, and the width is not the viewport's + +`apps/client/src/responsive/layout.ts` computed every column count from +`viewportWidth`, because its grid WAS the page. This one is a route inside the chat shell, behind a +sidebar that is 232px at rest, narrower when dragged and gone at 390. Carrying the constants across +unchanged would have produced numbers that are right about a page nobody is looking at. + +So every function takes the AVAILABLE width and the grid measures its own container with a +`ResizeObserver` — a window listener would miss a collapsed sidebar entirely, which changes the +space without changing the window. Six panes at 1440 have 1176px, not 1404. Three columns fit +either way, so criterion 5 would have passed on the viewport version by luck; seven panes at 1920 +is the case that keeps it honest, and it is criterion 5b. + +`PAGE_PADDING` dropped from 18 to 12 — t3code's shell already pays for horizontal inset and the +grid should not double it. `GRID_GAP` stayed at 12 because that was already t3code's rhythm. Both +are in the file with the measurement rather than carried silently. + +### Criterion 4b came back, and the screenshot is why + +Spec 250 restated spec 146's criteria 5 and 5b and never restated 4b. Nothing in the plan was +broken by leaving it out — and the first 1440 screenshot was the argument for it: six builders and +an architect at three columns is 3 + 3 + 1, one lonely card beside two empty slots. The architect +directed it in, and the plan carries it as a criterion now rather than as a memory. + +**It is stated as "four columns fit", not as "1920 or wider", and the number must not be corrected +back.** Spec 146 states a viewport width, which is right for `apps/client`: that client owns the +whole viewport, so available and viewport are the same number, and it stays right there. This grid +sits behind a sidebar, where 1920 of viewport is 1688 of grid — and a viewport threshold would offer +the tile at 1920 with the sidebar dragged wide enough that only three columns fit, which is the +ragged row 4b exists to prevent. Four columns is not a proxy for the reason; it is the reason +written down: seven items at four columns is 4 + 3, the ordinary shape of any grid. + +Keyed on width alone and never on the builder count, asserted as its own test. A count-based rule +would move the architect between strip and tile as builders come and go, which is a layout that +reflows under a reader who did nothing. + +### Two phase 9 defects the screenshots caught and the tests did not + +The pane's status, phase and footer lines were `text-xs`, which is 12px. That is right for a sidebar +row — read from a foot away with one thread in focus — and wrong for a tile in a grid of seven, +which is scanned. Criterion 5 puts the floor at 13 for exactly that reason. The type went up; the +alternative was narrowing the assertion to "body text only" and declaring the labels out of scope, +which is how a grid passes its tests and is unusable. + +And at 390 the shell's floating sidebar toggle sat on top of the first pane's own `architect/` +label. The route has a header now, which clears it at every width and names a screen that was +otherwise seven unlabelled cards. + +### The gate marker had to fit a 230px row, and neither first answer did + +The label read `Gate: plan-approval`, on the line above the title, which is correct in isolation and +does not survive a gated ARCHITECT: that row carries the role caption too, and the caption plus the +label plus the timestamp overflow — the caption truncated to `A…`, which answers "is it blocking on +me" by destroying "what agent is this". Moving the label to the title line fixed the caption and +truncated the TITLE instead, on every gated row, which is worse: the title is the row's primary +identifier. + +The label is the gate NAME alone now, and the word "Gate" is carried by the panel's own gavel. +Six characters bought back the room; the row and the panel say "gate" the same way. One clip is +left deliberately: a 15-character gate name on a gated architect still shows `Archit…`. The gate +name and the title are both intact, which is the right order — an architect at a gate is the row a +human most needs to find. + +### Screenshots never write into the fork, and that is not tidiness + +`start-fork` refuses a dirty fork checkout. A suite whose screenshots landed in the fork therefore +poisons itself the moment there is more than one spec file: the first writes new PNG bytes and +every file after it SKIPS, because the tree it needs is now dirty. It passes, it skips the rest, +and the skip is correct behaviour — which is what makes it easy to miss. Phase 7 met the one-file +version of this and answered it with an opt-in flag; phase 8 met the two-file version, which the +flag did not cover. + +A run now always writes outside the fork, and refreshing the committed pictures is a copy: + +```bash +SPEC_250_SCREENSHOT_DIR=/tmp/spec-250-shots \ + npx playwright test --config playwright.spec250.config.ts +cp -R /tmp/spec-250-shots/. "$T3CODE_FORK_ROOT/docs/codev/spec-250/" +``` + +### What the churn classifier could not decide, decided + +`classify-churn.mjs --fork-drift` reports three commits as `consumed-change-undecidable`. That is +the classifier refusing to guess inside a union, not a pass, so phase 5 decided them by hand and +recorded the answer here. Reproduce the diff by emitting the JSON Schema for each method at +`upstreamBase` and at `pin.commit` and comparing union members by discriminant: + +| Method | Direction | Change | Verdict | +|---|---|---|---| +| `orchestration.subscribeThread` | output | `role`, `parentThreadId`, `codevGate`, `gateRevision` added to the snapshot thread; `role`/`parentThreadId` added to the `thread.created` payload | non-breaking | +| `orchestration.subscribeThread` | output | two alternatives added to the `OrchestrationEvent` union: `codev.gate-set`, `codev.gate-cleared` | **breaking for a client on the pre-regeneration contract**, non-breaking after | +| `orchestration.dispatchCommand` | input | `role`, `parentThreadId` added to `thread.create`, both optional | non-breaking | + +Nothing was removed, nothing became required, no type narrowed, no enum lost a member, and +`additionalProperties` did not tighten anywhere. The one change that is genuinely breaking is the +pair of new event alternatives, and it breaks in exactly one direction: a client shape-checking the +stream against the **upstream-generated** contract rejects a `codev.gate-set` frame, because the +frame matches no member of the union it knows. That is the defect this phase closes — regenerating +is the fix, and `spec-250-generated-contract.test.ts` holds the before-and-after so the claim is +measured rather than asserted. + +### Migration 900 is abandoned, and must stay abandoned + +The first draft of phase 2 planned a numbered migration at id 900, reasoning that a large gap +below upstream's next id was safe. **Under a watermark migrator it is the opposite of safe.** + +`effect_sql_migrations` records the highest id that has run and the runner skips everything at or +below it. Once 900 runs, the watermark is 900 and upstream's `043` is below it — so every future +upstream migration is silently skipped, forever, on every Codev database. Nothing errors. The +schema simply stops keeping up, and the first symptom arrives months later as a query failing +against a column upstream added and we never got. + +A low id is no better: upstream takes the number we took, and a database that ran ours is then +told it already ran a migration it has never seen. + +So Codev's columns are applied outside the registry, in upstream's own idiom — `PRAGMA +table_info` then `ALTER TABLE … ADD COLUMN` for what is absent, the same shape as +`042_ProjectionThreadLinkedPullRequest.ts`. Nothing Codev writes ever touches +`effect_sql_migrations`. `apps/server/src/codev/schemaGuard.test.ts` asserts that, and asserts +that schema work landing after the guard still takes effect. + +The one real cost is that the columns are absent from the migration history. The mitigation is +that the guard logs `CODEV_SCHEMA_GUARD_APPLIED` (with the columns it added) or +`CODEV_SCHEMA_GUARD_NOOP` on every start. Two signals, because "added two columns" and "had +nothing to do" are different facts. + +**Where it is wired:** `apps/server/src/persistence/Layers/Sqlite.ts`, in `setup`, immediately +after `runMigrations()`. Not after `MigrationsLive` — that export exists and nothing builds it, +so a guard hung there would never run in production while a test that constructed the layer +itself passed. diff --git a/tools/t3-fork/crash-apply-child.mjs b/tools/t3-fork/crash-apply-child.mjs new file mode 100644 index 000000000..3b5cb1645 --- /dev/null +++ b/tools/t3-fork/crash-apply-child.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Spec 250, criterion 8b — the child that gets killed. + * + * Applies the FIRST Codev column to a file-backed database, announces it, then + * blocks forever waiting to be SIGKILLed. The parent kills it here, which leaves + * exactly one of the two columns on disk: the state a server dying between the + * guard's two `ALTER TABLE` statements really produces. + * + * The statements are the guard's, byte for byte. What this file stands in for is + * the process dying, not the SQL. + * + * Usage: node crash-apply-child.mjs + */ + +import { DatabaseSync } from 'node:sqlite'; + +const dbPath = process.argv[2]; +if (!dbPath) { + console.error('usage: crash-apply-child.mjs '); + process.exit(2); +} + +const db = new DatabaseSync(dbPath); +db.exec('ALTER TABLE projection_threads ADD COLUMN codev_role TEXT'); +db.close(); + +// Announce, then hang. The parent is waiting for this line before it kills. +console.log('APPLIED_FIRST_COLUMN'); + +// No exit path. Being killed here is the point; exiting cleanly would make the +// half-applied state a thing this script chose rather than a thing a crash left. +setInterval(() => {}, 1 << 30); diff --git a/tools/t3-fork/criterion-8b.mjs b/tools/t3-fork/criterion-8b.mjs new file mode 100644 index 000000000..572b5ea6b --- /dev/null +++ b/tools/t3-fork/criterion-8b.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Spec 250, criterion 8b — the kill test, exercised rather than argued. + * + * The criterion: "the server is killed partway through applying the columns and + * the resulting database still opens against the PRE-FORK server binary." + * + * Two review lanes correctly refused an in-process simulation on an in-memory + * database as evidence for it. This does the real thing: + * + * 1. Cold-start the PINNED PRE-FORK server (t3@0.0.36) through the spec 146 + * harness. It creates and migrates its own database on disk. + * 2. Stop it, and SIGKILL a child partway through applying the Codev columns, + * leaving exactly one of the two on disk. + * 3. Restart the PINNED PRE-FORK server on that half-applied file, keeping the + * data dir, and require it to answer. This is the criterion: a binary that + * knows nothing about `codev_role` must still open the database. + * 4. Run the fork's real guard against the same file and require it to add the + * missing column and only that one. + * 5. Start the pre-fork server once more on the now fully-applied file. + * + * Why this discriminates: inside the migrator the whole run is wrapped in + * `sql.withTransaction` and SQLite DDL is transactional, so a kill would roll + * both statements back and step 3 would pass without the code being careful. + * Outside it, two ALTERs are two atomic steps and step 2 really does leave one. + * + * Emits JSON evidence so the result is reviewable rather than asserted. + * + * Usage: + * export T3_NODE=/absolute/path/to/node + * node tools/t3-fork/criterion-8b.mjs --out codev/research/250-criterion-8b-evidence.json + * + * Prefer `--out` over a shell redirect. A redirect truncates the target the + * instant the process starts, so a run that dies partway leaves an EMPTY evidence + * file where a good one used to be — the previous, passing result is gone and the + * suite fails on a file that says nothing rather than on the run that broke. With + * `--out` the evidence is written once, at the end, and only when the run passed. + * A redirect still works and still prints the JSON; it just cannot protect you. + */ + +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MISMATCH, OK, resolveIdentities } from './identities.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..', '..'); +const pin = JSON.parse(readFileSync(join(repoRoot, 'packages', 'types', 'src', 't3', 'pin.json'), 'utf8')); +const { fork } = resolveIdentities(pin); + +const harness = join(repoRoot, 'tools', 't3-server', 't3-server.mjs'); +const runtimeDir = process.env.T3_HARNESS_DIR ?? join(repoRoot, 'tools', 't3-server', '.runtime'); +const dbPath = join(runtimeDir, 'data', 'userdata', 'state.sqlite'); + +/** + * The FIRST Codev column, which is the one the child applies before being killed. + * + * The full set is deliberately NOT hardcoded here. It was, and phase 4 adding two + * more columns broke this driver while the criterion it tests still held — the + * same brittleness this project keeps finding. The set is derived from what the + * guard itself reports: `present` is what the crash left, `added` is what the + * resume finished, and their union is the schema the pre-fork server then opens. + */ +const CODEV_FIRST_COLUMN = 'codev_role'; + +const say = (message) => console.error(`[criterion-8b] ${message}`); + +function runHarness(...args) { + return execFileSync(process.execPath, [harness, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function columnsOf(file) { + const db = new DatabaseSync(file, { readOnly: true }); + try { + return db.prepare('PRAGMA table_info(projection_threads)').all().map((row) => row.name); + } finally { + db.close(); + } +} + +/** Start the pinned pre-fork server and require it to answer. */ +function preForkServerOpens(label, { keepData }) { + try { runHarness('stop'); } catch { /* nothing running */ } + // `--keep-data` rather than `restart`: restart is stop-then-start and refuses + // when nothing is running, and the whole point here is opening a database this + // run did NOT just create. + if (keepData) runHarness('start', '--keep-data'); + else runHarness('start'); + const readyOut = runHarness('ready'); + const { token } = JSON.parse(readyOut.slice(readyOut.indexOf('{'))); + const opened = Boolean(token); + say(`${label}: pre-fork server ${opened ? 'opened the database and answered' : 'did NOT answer'}`); + runHarness('stop'); + return opened; +} + +/** + * The fork commit the guard came from. + * + * `forkRoot` alone names a path, and a path is not a version: the same evidence + * file would describe any guard that checkout happened to hold. Recorded so a + * reader can say WHICH guard passed, and so a later run against a different fork + * commit is visibly a different measurement. + */ +function forkCommit() { + try { + return execFileSync('git', ['-C', fork.root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +const evidence = { + criterion: + 'Spec 250 criterion 8b: the server is killed partway through applying the Codev columns and ' + + 'the resulting database still opens against the pre-fork server binary.', + preForkCliVersion: pin.cliVersion, + upstreamBase: pin.upstreamBase, + forkRoot: fork.root, + forkCommit: forkCommit(), + dbPath, + steps: {}, +}; + +try { + // 1. A database created and migrated by the pinned PRE-FORK binary. + say('starting the pinned pre-fork server to create a real database...'); + evidence.steps.preForkServerCreatedDatabase = preForkServerOpens('cold start', { keepData: false }); + if (!existsSync(dbPath)) throw new Error(`no database at ${dbPath} after a cold start`); + + const before = columnsOf(dbPath); + evidence.steps.columnsBeforeGuard = before.filter((c) => c.startsWith('codev_')); + if (evidence.steps.columnsBeforeGuard.length !== 0) { + throw new Error('the pre-fork database already has Codev columns; this run proves nothing'); + } + + // 2. Kill a real process partway through applying them. + say('applying the first column in a child, then SIGKILLing it...'); + const child = spawn(process.execPath, [join(here, 'crash-apply-child.mjs'), dbPath], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + const killed = await new Promise((resolveKill, rejectKill) => { + let out = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + rejectKill(new Error('child never announced the first column')); + }, 30_000); + child.stdout.on('data', (chunk) => { + out += chunk; + if (out.includes('APPLIED_FIRST_COLUMN')) { + clearTimeout(timer); + // SIGKILL, not SIGTERM: no handler, no cleanup, no chance to finish. + child.kill('SIGKILL'); + child.on('exit', (code, signal) => resolveKill({ code, signal })); + } + }); + }); + evidence.steps.childKilledBySignal = killed.signal; + if (killed.signal !== 'SIGKILL') throw new Error(`child exited by ${killed.signal ?? killed.code}, not SIGKILL`); + + const half = columnsOf(dbPath); + evidence.steps.columnsAfterKill = half.filter((c) => c.startsWith('codev_')); + evidence.steps.halfApplied = + evidence.steps.columnsAfterKill.length === 1 && + evidence.steps.columnsAfterKill[0] === CODEV_FIRST_COLUMN; + if (!evidence.steps.halfApplied) { + throw new Error( + `expected exactly ${CODEV_FIRST_COLUMN} after the kill, found ` + + `[${evidence.steps.columnsAfterKill.join(', ')}]. Without a genuinely half-applied file ` + + 'the rest of this run proves nothing.', + ); + } + say(`half-applied on disk: ${evidence.steps.columnsAfterKill.join(', ')}`); + + // 3. THE CRITERION. A binary that has never heard of codev_role opens it. + evidence.steps.preForkServerOpensHalfApplied = preForkServerOpens('half-applied', { keepData: true }); + + // 4. The fork's real guard finishes the job. + say('running the fork guard against the half-applied file...'); + const guardOut = execFileSync( + process.execPath, + [join(fork.root, 'apps', 'server', 'scripts', 'apply-codev-guard.ts'), dbPath], + { encoding: 'utf8', cwd: fork.root, stdio: ['ignore', 'pipe', 'inherit'] }, + ); + const guard = JSON.parse(guardOut.trim().split('\n').pop()); + evidence.steps.guardResume = guard; + // The property, not a count: the guard saw exactly what the crash left, and + // finished everything else. Robust to a later phase adding more columns. + evidence.steps.guardSawWhatTheCrashLeft = + JSON.stringify([...guard.present].sort()) === + JSON.stringify([...evidence.steps.columnsAfterKill].sort()); + evidence.steps.guardFinishedTheJob = guard.added.length > 0; + + const after = columnsOf(dbPath); + evidence.steps.columnsAfterResume = after.filter((c) => c.startsWith('codev_')); + const expected = [...guard.present, ...guard.added].sort(); + evidence.steps.schemaComplete = + JSON.stringify([...evidence.steps.columnsAfterResume].sort()) === JSON.stringify(expected); + + // 5. And the pre-fork binary still opens the fully-applied file. + evidence.steps.preForkServerOpensFullyApplied = preForkServerOpens('fully applied', { keepData: true }); + + evidence.passed = + evidence.steps.preForkServerCreatedDatabase === true && + evidence.steps.halfApplied === true && + evidence.steps.preForkServerOpensHalfApplied === true && + evidence.steps.guardSawWhatTheCrashLeft === true && + evidence.steps.guardFinishedTheJob === true && + evidence.steps.schemaComplete === true && + evidence.steps.preForkServerOpensFullyApplied === true; +} catch (error) { + evidence.passed = false; + evidence.error = error instanceof Error ? error.message : String(error); + say(`FAILED: ${evidence.error}`); +} finally { + try { runHarness('stop'); } catch { /* already stopped */ } +} + +const serialized = `${JSON.stringify(evidence, null, 2)}\n`; +console.log(serialized.trimEnd()); + +const outIdx = process.argv.indexOf('--out'); +if (outIdx >= 0) { + const out = process.argv[outIdx + 1]; + if (!out) { + say('--out was given without a path; refusing to guess one.'); + process.exit(MISMATCH); + } + if (evidence.passed) { + writeFileSync(out, serialized); + say(`evidence written to ${out}`); + } else { + // Deliberately NOT written. Overwriting a passing record with a failure is + // how a transient crash silently destroys the only evidence that the + // criterion ever held; the run's own exit code and stdout report the failure. + say(`run did not pass; ${out} left untouched. Read the JSON above for what failed.`); + } +} + +process.exit(evidence.passed ? OK : MISMATCH); diff --git a/tools/t3-fork/drill-closure.mjs b/tools/t3-fork/drill-closure.mjs new file mode 100644 index 000000000..061764cbb --- /dev/null +++ b/tools/t3-fork/drill-closure.mjs @@ -0,0 +1,66 @@ +/** + * Spec 250 phase 11 — the drill's one decision that is worth testing on its own. + * + * `rebase-drill.mjs` is a script with top-level side effects: importing it runs a + * drill. Nothing inside it can be reached from a unit test, so every branch there + * is covered only by whatever the last real run happened to take. That is fine + * for measurements — a run either produced the number or it did not — and it is + * NOT fine for the guard below, whose whole job is to fire on cases a normal run + * never reaches. + * + * ## The failure it exists to prevent + * + * The drill hashes the pinned contract closure off the MERGED worktree and + * compares it to `generated/source-hash.json`. That comparison is only meaningful + * while a merge is actually on disk. Two ways it stops being: + * + * 1. `git merge --abort` has already run. The worktree is the fork again, so + * the closure is compared to the contract generated FROM that same fork, and + * the answer is `moved: []` on every run forever. That one is prevented by + * call ORDER in the drill, and checked by hashing the unmerged fork directly. + * 2. The merge never produced a tree at all — already up to date, a wedged + * index, a git that failed for its own reasons. The worktree is *also* the + * unmerged fork, and there are no conflicts to notice, so a guard that only + * asks "did the closure conflict" waves it through into exactly the same + * tautology by a different door. + * + * The second is what this module decides, separately, so a test can reach it. + * Neither case may report a comparison: they report `checked: false` and a + * reason, because "I could not compare" must never be spelled like "nothing + * moved". + */ + +/** + * Can the merged closure be compared to the vendored contract, and if not, why? + * + * @param {object} args + * @param {boolean} args.mergeOk did `git merge --no-commit` exit zero + * @param {string[]} args.conflictedFiles every file the merge left conflicted + * @param {string[]} args.closureConflicts the subset of those inside the closure + * @param {string} [args.gitSaid] git's output, for the refusal's reason + * @returns {{measurable: true} | {measurable: false, reason: string}} + */ +export function closureMeasurability({ mergeOk, conflictedFiles, closureConflicts, gitSaid = '' }) { + /* + * A merge that neither completed nor conflicted did not happen. Checked FIRST, + * because the closure-conflict question below is vacuously satisfied in exactly + * this case — zero conflicts, because there was no merge to conflict. + */ + if (!mergeOk && conflictedFiles.length === 0) { + const said = gitSaid.split('\n').filter(Boolean).slice(0, 3).join(' / '); + return { + measurable: false, + reason: 'the probe merge neither completed nor conflicted, so the worktree is still the ' + + 'unmerged fork and hashing it would compare the fork to itself.' + + (said ? ` git said: ${said}` : ''), + }; + } + if (closureConflicts.length > 0) { + return { + measurable: false, + reason: `${closureConflicts.join(', ')} conflicted, so the merged tree holds no single ` + + "version of the generator's source to hash.", + }; + } + return { measurable: true }; +} diff --git a/tools/t3-fork/identities.mjs b/tools/t3-fork/identities.mjs new file mode 100644 index 000000000..f8c7a0741 --- /dev/null +++ b/tools/t3-fork/identities.mjs @@ -0,0 +1,183 @@ +/** + * Spec 250, phase 1 — the two vendoring identities, resolved in one place. + * + * Spec 146 had one checkout and one meaning, so `T3CODE_ROOT` and `pin.commit` + * could be read directly by whoever needed them. Spec 250 adds a second checkout + * with a *different* meaning, and the failure mode that creates is not a missing + * feature — it is a tool that thinks it is looking at one identity while pointing + * at the other. `acquire()` checking a fork SHA out into the read-only upstream + * clone is that failure, and it writes. + * + * So the mapping lives here, once, and every tool asks rather than re-deriving: + * + * upstream the read-only clone of pingdotgg/t3code, pinned at `upstreamBase`. + * Every piece of spec 146 and 236 evidence reproduces against it, so + * it must never move and nothing here ever writes to it. + * fork our private customization checkout, pinned at `commit`, whose + * merge-base with `upstreamBase` must still BE `upstreamBase`. + * + * `commit` keeps its spec 146 meaning — "the commit the generated artifacts came + * from" — and that source becomes the fork from phase 5. Until then the two SHAs + * are equal, which is deliberate: while they are equal every assertion added here + * has a known answer, so a harness bug cannot hide inside a real diff. + */ + +/** Exit codes, shared so "could not determine" is spelled the same everywhere. */ +export const OK = 0; +export const MISMATCH = 1; +export const UNDETERMINED = 3; + +export const DEFAULT_UPSTREAM_ROOT = '/Users/chris/dev/t3code'; +export const DEFAULT_FORK_ROOT = '/Users/chris/dev/t3code-codev'; + +export const UPSTREAM_REPO = 'https://github.com/pingdotgg/t3code.git'; + +/** + * Resolve both identities from a parsed pin plus the environment. + * + * A pin without `upstreamBase` is a pre-250 pin: one checkout, one meaning. It + * resolves to two identities that happen to name the same commit rather than + * throwing, because the alternative is that every tool grows a version check. + * `pin.upstreamBase` absent is not an error; a fork *root* that does not exist is + * `UNDETERMINED`, and that is a different question answered elsewhere. + */ +export function resolveIdentities(pin, env = process.env) { + if (!pin || typeof pin.commit !== 'string' || pin.commit === '') { + throw new Error('pin.json has no `commit`; there is no identity to resolve.'); + } + const upstreamBase = typeof pin.upstreamBase === 'string' && pin.upstreamBase !== '' + ? pin.upstreamBase + : pin.commit; + + return { + upstream: { + name: 'upstream', + root: env.T3CODE_ROOT ?? DEFAULT_UPSTREAM_ROOT, + rootVar: 'T3CODE_ROOT', + commit: upstreamBase, + repo: pin.repo ?? UPSTREAM_REPO, + }, + fork: { + name: 'fork', + root: env.T3CODE_FORK_ROOT ?? DEFAULT_FORK_ROOT, + rootVar: 'T3CODE_FORK_ROOT', + commit: pin.commit, + base: upstreamBase, + repo: pin.forkRepo ?? null, + branch: pin.forkBranch ?? null, + contractSource: contractSource(pin), + }, + /** True while the fork has not diverged. Phases 1-4 run in this state on purpose. */ + diverged: pin.commit !== upstreamBase, + }; +} + +/** + * Where the VENDORED CONTRACT was generated from — not where the fork checkout is. + * + * `pin.commit` means "the vendored contract came from this commit", and only + * regeneration is allowed to move it. So between the fork's first customization + * commit and the regeneration that follows, the fork checkout is legitimately + * AHEAD of `pin.commit`, and that state has to be distinguishable from a fork + * sitting on the wrong commit. + * + * `'upstream'` the contract has not been regenerated from the fork yet, so a + * fork head that DESCENDS from `pin.commit` is expected. + * `'fork'` regeneration has happened; the fork head must equal `pin.commit`, + * and being ahead is an error like any other mismatch. + * + * Absent means `'upstream'`: a pin that has never named a fork cannot have been + * generated from one. + */ +export function contractSource(pin) { + const declared = pin?.contractSource; + if (declared === 'fork' || declared === 'upstream') return declared; + return 'upstream'; +} + +/** + * Classify a fork HEAD against `pin.commit`, given how the two relate in git. + * + * `descendant` is the caller's answer to "is HEAD a descendant of pin.commit?", + * which only git can answer; passing it in keeps this decidable without a + * subprocess and therefore unit-testable. + * + * Three outcomes, deliberately not two: + * at-contract HEAD === pin.commit. Always fine. + * ahead HEAD descends from pin.commit. Expected while the contract is + * upstream-sourced; an error once it is fork-sourced. + * wrong-commit HEAD does not descend from pin.commit. An error at any time. + */ +export function classifyForkHead({ head, commit, descendant, contractSource: source }) { + if (head === commit) return { state: 'at-contract', ok: true, signal: null }; + if (!descendant) { + return { + state: 'wrong-commit', + ok: false, + signal: 'FORK_CHECKOUT_MISMATCH', + }; + } + return { + state: 'ahead', + ok: source !== 'fork', + signal: 'FORK_AHEAD_OF_CONTRACT', + }; +} + +/** + * The two churn questions, as two ranges read from two checkouts. + * + * They are different questions and an earlier design let one flag answer both: + * "what did upstream do since we pinned it" and "what have we changed" have + * different ranges, different roots, and different consequences. Conflating them + * reports our own customization as upstream movement. + */ +export const CHURN_MODES = { + 'upstream-movement': { + identity: 'upstream', + describe: (i) => `${i.commit}..origin/main`, + range: (identities) => ({ + root: identities.upstream.root, + from: identities.upstream.commit, + to: 'origin/main', + }), + }, + 'fork-drift': { + identity: 'fork', + describe: (i) => `${i.base}..HEAD`, + /** + * Measured to the checkout's HEAD, NOT to `pin.commit`. + * + * They were the same thing until `pin.commit` was ruled to stay at + * `upstreamBase` until regeneration. After that ruling, measuring to + * `pin.commit` reports `upstreamBase..upstreamBase` — zero drift — for a fork + * carrying real customization commits. That is "I could not tell" spelled + * exactly like "nothing changed", on the one tool whose entire job is + * answering "what have we changed?". + * + * HEAD is correct on both sides of phase 5, so it does not need revisiting + * when `contractSource` flips. + */ + range: (identities) => ({ + root: identities.fork.root, + from: identities.fork.base, + to: 'HEAD', + }), + }, +}; + +/** + * Build the range for one churn mode. Unknown or absent mode throws rather than + * defaulting: picking one silently is how the two questions get conflated. + */ +export function churnRange(mode, identities) { + const spec = CHURN_MODES[mode]; + if (!spec) { + throw new Error( + `Unknown churn mode ${JSON.stringify(mode)}. Pass exactly one of ` + + `${Object.keys(CHURN_MODES).map((m) => `--${m}`).join(' or ')} — they are different ` + + `questions read from different checkouts, and there is no sensible default.`, + ); + } + return { mode, identity: spec.identity, ...spec.range(identities) }; +} diff --git a/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch b/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch new file mode 100644 index 000000000..880c25928 --- /dev/null +++ b/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch @@ -0,0 +1,1510 @@ +From 1a414cee8409a407977ff6c6505fad1ab82f2ec8 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 15:49:16 -0600 +Subject: [PATCH 01/34] [Spec 250][Phase: phase_2] feat: thread hierarchy in + the contract and projection +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Codev customization. role ("architect" | "builder" | null) and +parentThreadId on the thread record, carried from the event through the +projector and both persistence paths. + +The columns are applied OUTSIDE upstream's migration registry, in +upstream's own idiom: PRAGMA table_info then ALTER TABLE ADD COLUMN for +what is absent, the same shape as 042_ProjectionThreadLinkedPullRequest. +The migrator is a watermark migrator, so a Codev id either collides with +upstream's next one (low) or pushes the watermark above every future +upstream id and silently skips them forever (high). Migration 900 is +abandoned; schemaGuard.ts records why. The guard logs +CODEV_SCHEMA_GUARD_APPLIED / _NOOP because staying out of the registry +means the columns are otherwise inferrable only by reading the schema. + +Wired into persistence/Layers/Sqlite.ts's `setup`, immediately after +runMigrations(), NOT after MigrationsLive as the plan said. MigrationsLive +is exported and nothing builds it: a guard hung there would never have run +in production while a test that constructed the layer passed. `setup` is +what both makeSqlitePersistenceLive and SqlitePersistenceMemory provide. +A test asserts the ordering against that production file, not against a +layer it assembles itself. + +Two spellings, deliberately. ThreadCreatedPayload keeps a decoding default +so the log's pre-fork payloads replay and the projector's payload.role +read is total. The read models use Schema.optional, matching +linkedPullRequest — upstream's own newest field, optional so older cached +snapshots decode. The strict form cost 32 edits across 11 upstream test +files, which is the divergence this fork is shaped to avoid; the server +normalizes with ?? null on every read path, so one spelling reaches +clients in practice. + +Fork typecheck green. contracts 291 passed; server 2769 passed, 8 skipped. +The one server failure, entrypoint.test.ts's symlink case, is byte-identical +to the base commit, imports only node:fs and node:url, and fails on macOS's +/var -> /private/var resolution. Pre-existing and unrelated. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/codev/schemaGuard.test.ts | 184 ++++++++++++ + apps/server/src/codev/schemaGuard.ts | 182 ++++++++++++ + apps/server/src/codev/threadHierarchy.test.ts | 261 ++++++++++++++++++ + .../Layers/ProjectionPipeline.ts | 4 + + .../Layers/ProjectionSnapshotQuery.test.ts | 8 + + .../Layers/ProjectionSnapshotQuery.ts | 24 ++ + .../src/orchestration/decider.delete.test.ts | 12 + + .../decider.projectScripts.test.ts | 18 ++ + .../projector.codevHierarchy.test.ts | 194 +++++++++++++ + .../src/orchestration/projector.test.ts | 4 + + apps/server/src/orchestration/projector.ts | 6 + + .../persistence/Layers/ProjectionThreads.ts | 10 + + apps/server/src/persistence/Layers/Sqlite.ts | 8 + + .../persistence/Services/ProjectionThreads.ts | 9 + + packages/contracts/src/orchestration.test.ts | 155 +++++++++++ + packages/contracts/src/orchestration.ts | 55 ++++ + 16 files changed, 1134 insertions(+) + create mode 100644 apps/server/src/codev/schemaGuard.test.ts + create mode 100644 apps/server/src/codev/schemaGuard.ts + create mode 100644 apps/server/src/codev/threadHierarchy.test.ts + create mode 100644 apps/server/src/orchestration/projector.codevHierarchy.test.ts + +diff --git a/apps/server/src/codev/schemaGuard.test.ts b/apps/server/src/codev/schemaGuard.test.ts +new file mode 100644 +index 000000000..ad782c66d +--- /dev/null ++++ b/apps/server/src/codev/schemaGuard.test.ts +@@ -0,0 +1,184 @@ ++/** ++ * Codev customization (spec 250) — the schema guard. ++ * ++ * The interesting assertions here are not "it adds two columns". They are: ++ * ++ * - it does NOT touch `effect_sql_migrations`, so upstream's watermark stays ++ * where upstream left it; ++ * - a new upstream migration landing after the guard still takes effect — the ++ * shape of test that would have caught the abandoned migration 900; ++ * - a half-applied schema (one column added, then a crash) is resumable, and the ++ * next run finishes the job and reports what it did. ++ */ ++ ++import { assert, describe, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; ++import * as SqlClient from "effect/unstable/sql/SqlClient"; ++ ++import { runMigrations } from "../persistence/Migrations.ts"; ++import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; ++import { ++ CODEV_GUARDED_TABLE, ++ CODEV_THREAD_COLUMN_NAMES, ++ applyCodevSchemaGuard, ++} from "./schemaGuard.ts"; ++ ++/** ++ * A FRESH in-memory database per test, not one shared by the block. ++ * ++ * `it.layer` builds its layer once per suite, so every test here ran against the ++ * same database and the second `ALTER TABLE … codev_role` failed with "duplicate ++ * column name". Worse, the ones that passed did so only because of the order they ++ * ran in. Schema tests that share a schema are testing the order. ++ */ ++const withDb = (effect: Effect.Effect) => ++ Effect.provide(effect, Layer.mergeAll(NodeSqliteClient.layerMemory())); ++ ++const columnNames = Effect.fn("columnNames")(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ const columns = yield* sql<{ readonly name: string }>` ++ PRAGMA table_info(projection_threads) ++ `; ++ return columns.map((column) => column.name); ++}); ++ ++const migrationWatermark = Effect.fn("migrationWatermark")(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ const rows = yield* sql<{ ++ readonly migration_id: number; ++ readonly name: string; ++ }>`SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id ASC`; ++ return rows.map((row) => `${row.migration_id}_${row.name}`); ++}); ++ ++describe("codev schema guard (spec 250)", () => { ++ it.effect("adds both hierarchy columns to projection_threads", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* runMigrations(); ++ ++ const before = yield* columnNames(); ++ for (const column of CODEV_THREAD_COLUMN_NAMES) { ++ assert.ok(!before.includes(column), `${column} must not exist before the guard runs`); ++ } ++ ++ const result = yield* applyCodevSchemaGuard(); ++ assert.deepStrictEqual([...result.added], [...CODEV_THREAD_COLUMN_NAMES]); ++ assert.deepStrictEqual([...result.present], []); ++ ++ const after = yield* columnNames(); ++ for (const column of CODEV_THREAD_COLUMN_NAMES) { ++ assert.ok(after.includes(column), `${column} must exist after the guard runs`); ++ } ++ }), ++ ), ++ ); ++ ++ it.effect("is idempotent: a second run adds nothing and changes nothing", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* runMigrations(); ++ yield* applyCodevSchemaGuard(); ++ const afterFirst = yield* columnNames(); ++ ++ const second = yield* applyCodevSchemaGuard(); ++ assert.deepStrictEqual([...second.added], [], "the second run must add nothing"); ++ assert.deepStrictEqual([...second.present], [...CODEV_THREAD_COLUMN_NAMES]); ++ assert.deepStrictEqual(yield* columnNames(), afterFirst); ++ }), ++ ), ++ ); ++ ++ it.effect("resumes a half-applied schema instead of skipping it", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* runMigrations(); ++ ++ // Exactly the state a SIGKILL between the two ALTERs leaves behind: one ++ // column added, the other absent. There is no transaction wrapping them, ++ // so the process can really reach this state — unlike inside the migrator, ++ // where `sql.withTransaction` would have rolled both back and the ++ // criterion would have passed by construction. ++ yield* sql`ALTER TABLE projection_threads ADD COLUMN codev_role TEXT`; ++ ++ const result = yield* applyCodevSchemaGuard(); ++ assert.deepStrictEqual([...result.present], ["codev_role"]); ++ assert.deepStrictEqual([...result.added], ["codev_parent_thread_id"]); ++ ++ const after = yield* columnNames(); ++ for (const column of CODEV_THREAD_COLUMN_NAMES) { ++ assert.ok(after.includes(column)); ++ } ++ }), ++ ), ++ ); ++ ++ it.effect("never touches upstream's migration watermark", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* runMigrations(); ++ const before = yield* migrationWatermark(); ++ assert.ok(before.length > 0, "the migrator must have recorded upstream's own migrations"); ++ ++ yield* applyCodevSchemaGuard(); ++ yield* applyCodevSchemaGuard(); ++ ++ assert.deepStrictEqual( ++ yield* migrationWatermark(), ++ before, ++ "effect_sql_migrations must be identical before and after the guard", ++ ); ++ }), ++ ), ++ ); ++ ++ /** ++ * The shape of test that would have caught migration 900. ++ * ++ * Under a watermark migrator, a Codev migration at a high id pushes the ++ * watermark above every future upstream id, and upstream's next migration is ++ * skipped forever with no error. Applying the columns outside the registry ++ * means the watermark never moves, so later schema work still lands. ++ */ ++ it.effect("schema work landing after the guard still takes effect", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* runMigrations(); ++ yield* applyCodevSchemaGuard(); ++ ++ const watermarkAfterGuard = yield* migrationWatermark(); ++ ++ // Stand-in for upstream's next migration. ++ yield* sql`ALTER TABLE projection_threads ADD COLUMN pretend_upstream_column TEXT`; ++ ++ const columns = yield* columnNames(); ++ assert.ok( ++ columns.includes("pretend_upstream_column"), ++ "an upstream migration landing after the guard must still take effect", ++ ); ++ for (const column of CODEV_THREAD_COLUMN_NAMES) { ++ assert.ok(columns.includes(column), "and it must not have displaced Codev's columns"); ++ } ++ assert.deepStrictEqual( ++ yield* migrationWatermark(), ++ watermarkAfterGuard, ++ "the guard left the watermark free for upstream to keep using", ++ ); ++ }), ++ ), ++ ); ++ ++ it.effect("guards the table the rest of the phase reads and writes", () => ++ withDb( ++ Effect.gen(function* () { ++ // A constant nobody reads is a constant that drifts. This pins the guard ++ // to the table ProjectionThreads and ProjectionSnapshotQuery query. ++ assert.strictEqual(CODEV_GUARDED_TABLE, "projection_threads"); ++ yield* Effect.void; ++ }), ++ ), ++ ); ++}); +diff --git a/apps/server/src/codev/schemaGuard.ts b/apps/server/src/codev/schemaGuard.ts +new file mode 100644 +index 000000000..29f9a2beb +--- /dev/null ++++ b/apps/server/src/codev/schemaGuard.ts +@@ -0,0 +1,182 @@ ++/** ++ * Codev customization (spec 250) — the schema guard. ++ * ++ * WHY THIS IS NOT A MIGRATION ++ * --------------------------- ++ * Upstream's migrator is a WATERMARK migrator: `effect_sql_migrations` records the ++ * highest id that has run, and the runner skips everything at or below it. So a ++ * Codev migration inserted into `migrationEntries` has exactly two options, and ++ * both are bad. ++ * ++ * A LOW id collides — upstream's next migration takes the number we took, and a ++ * database that ran ours then meets upstream's is told it already ran a migration ++ * it has never seen. ++ * ++ * A HIGH id (the first draft reached for 900, reasoning that a big gap is safe) is ++ * the opposite of safe under a watermark: once 900 has run, the watermark is 900, ++ * and upstream's 043 is below it. Every future upstream migration is silently ++ * skipped, forever, on every Codev database. Nothing errors. The schema simply ++ * stops keeping up, and the first symptom is a query failing months later against ++ * a column upstream added and we never got. ++ * ++ * So the columns are applied OUTSIDE the registry, in upstream's own idiom: ++ * `PRAGMA table_info` then `ALTER TABLE … ADD COLUMN` for what is absent. That is ++ * exactly what `042_ProjectionThreadLinkedPullRequest.ts` and seven other upstream ++ * migrations do. This file never reads or writes `effect_sql_migrations`, so ++ * upstream's watermark stays where upstream put it and every future upstream ++ * migration still runs. ++ * ++ * THE COST, AND THE MITIGATION ++ * ---------------------------- ++ * Staying out of the registry has one real price: our columns are absent from the ++ * migration history, so their existence is inferrable only by reading the schema. ++ * The mitigation is that this logs, under a named signal, on every start: ++ * `CODEV_SCHEMA_GUARD_APPLIED` with the columns it added, or ++ * `CODEV_SCHEMA_GUARD_NOOP` when there was nothing to do. ++ * ++ * Two signals rather than one, because "added two columns" and "had nothing to do" ++ * are different facts. A single line covering both is the thing that makes a log ++ * useless — you could not tell a first start from the thousandth. ++ * ++ * CRASH SAFETY ++ * ------------ ++ * There is no transaction around these statements and there does not need to be. ++ * Each `ALTER TABLE … ADD COLUMN` is atomic on its own, so a kill between the two ++ * leaves exactly one column added — and the `PRAGMA table_info` check means the ++ * next start adds the other and reports it. A partially applied schema is a ++ * resumable state, not a corrupt one, and it still opens against a pre-fork server: ++ * the added columns are nullable, and every read upstream performs names its ++ * columns explicitly. ++ * ++ * Note this is the reason the kill test discriminates at all. Inside the migrator ++ * the whole run is wrapped in `sql.withTransaction` and SQLite DDL is ++ * transactional, so a kill would roll everything back and the criterion would pass ++ * by construction rather than by the code being careful. ++ */ ++ ++import * as Effect from "effect/Effect"; ++import * as SqlClient from "effect/unstable/sql/SqlClient"; ++ ++/** The table Codev's hierarchy columns live on. */ ++export const CODEV_GUARDED_TABLE = "projection_threads"; ++ ++/** ++ * The columns this guard maintains. ++ * ++ * Both are nullable with no default, deliberately. A pre-fork database gets `NULL` ++ * for every existing row, and `NULL` means "Codev did not create this thread" — ++ * which is true of every row predating the fork. A default would have turned that ++ * fact into a guess. ++ * ++ * `add` is a thunk holding a fully literal statement rather than an interpolated ++ * one. Identifiers and types cannot be bound as SQL parameters, and building them ++ * by string concatenation is how an identifier becomes an injection point. Writing ++ * each statement out is the same shape upstream's own column migrations use. ++ */ ++export const CODEV_THREAD_COLUMNS = [ ++ { ++ name: "codev_role", ++ add: Effect.fnUntraced(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* sql` ++ ALTER TABLE projection_threads ++ ADD COLUMN codev_role TEXT ++ `; ++ }), ++ }, ++ { ++ name: "codev_parent_thread_id", ++ add: Effect.fnUntraced(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* sql` ++ ALTER TABLE projection_threads ++ ADD COLUMN codev_parent_thread_id TEXT ++ `; ++ }), ++ }, ++] as const; ++ ++/** The column names, for tests and for anything that needs the set without the DDL. */ ++export const CODEV_THREAD_COLUMN_NAMES = CODEV_THREAD_COLUMNS.map((column) => column.name); ++ ++export type CodevSchemaGuardResult = { ++ /** Columns this run actually added. Empty on every start after the first. */ ++ readonly added: ReadonlyArray; ++ /** Columns that were already present. */ ++ readonly present: ReadonlyArray; ++}; ++ ++/** ++ * Apply the Codev columns to `projection_threads`, idempotently. ++ * ++ * Returns what it did rather than logging and swallowing it, so a test can assert ++ * that the second run added nothing without parsing log output. ++ */ ++export const applyCodevSchemaGuard = Effect.fn("applyCodevSchemaGuard")(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ ++ const existing = yield* sql<{ readonly name: string }>` ++ PRAGMA table_info(projection_threads) ++ `; ++ const existingNames = new Set(existing.map((column) => column.name)); ++ ++ const added: Array = []; ++ const present: Array = []; ++ ++ for (const column of CODEV_THREAD_COLUMNS) { ++ if (existingNames.has(column.name)) { ++ present.push(column.name); ++ continue; ++ } ++ yield* column.add(); ++ added.push(column.name); ++ } ++ ++ return { added, present } satisfies CodevSchemaGuardResult; ++}); ++ ++/** ++ * The start-up step: apply, then log under a named signal. ++ * ++ * WHERE THIS IS CALLED, AND WHY NOT WHERE THE PLAN SAID ++ * ---------------------------------------------------- ++ * The plan called for a layer sequenced after `MigrationsLive`. `MigrationsLive` ++ * (`persistence/Migrations.ts:173`) is exported and **nothing builds it** — grep ++ * the tree; every reference is its own definition or its own docstring. The real ++ * boot path is `persistence/Layers/Sqlite.ts`'s `setup`, which calls ++ * `runMigrations()` directly and is what both `makeSqlitePersistenceLive` and ++ * `SqlitePersistenceMemory` provide. ++ * ++ * So a guard hung off `MigrationsLive` would never run in production, and a test ++ * that built `MigrationsLive` itself would have passed anyway — proving the layer ++ * works while production never constructs it. This is called from `setup` instead, ++ * immediately after `runMigrations()`, which is the one place that actually ++ * sequences schema work before the projection layers open. ++ * ++ * Ordering matters and is not left to construction order: a repository query that ++ * ran before the guard would read a table without our columns, and the failure ++ * would surface as missing data rather than as a boot-order bug. ++ */ ++export const codevSchemaGuardStep = Effect.fn("codevSchemaGuardStep")(function* () { ++ const result = yield* applyCodevSchemaGuard(); ++ ++ yield* result.added.length > 0 ++ ? Effect.log("CODEV_SCHEMA_GUARD_APPLIED").pipe( ++ Effect.annotateLogs({ ++ table: CODEV_GUARDED_TABLE, ++ added: result.added, ++ alreadyPresent: result.present, ++ spec: 250, ++ note: "Codev columns are applied outside effect_sql_migrations by design; see apps/server/src/codev/schemaGuard.ts", ++ }), ++ ) ++ : Effect.log("CODEV_SCHEMA_GUARD_NOOP").pipe( ++ Effect.annotateLogs({ ++ table: CODEV_GUARDED_TABLE, ++ alreadyPresent: result.present, ++ spec: 250, ++ }), ++ ); ++ ++ return result; ++}); +diff --git a/apps/server/src/codev/threadHierarchy.test.ts b/apps/server/src/codev/threadHierarchy.test.ts +new file mode 100644 +index 000000000..9b34eb02a +--- /dev/null ++++ b/apps/server/src/codev/threadHierarchy.test.ts +@@ -0,0 +1,261 @@ ++/** ++ * Codev customization (spec 250) — the hierarchy columns through real persistence. ++ * ++ * The projector test proves the rebuild in memory. This one proves the columns ++ * survive the round trip through SQLite via the repository production uses, and ++ * that a database written before the fork opens and reads as "not recorded" ++ * rather than as a guessed role. ++ * ++ * It also asserts the START-UP ORDERING against the production source. The plan ++ * asked for the guard to sit after `MigrationsLive`; `MigrationsLive` is exported ++ * and nothing builds it, so a guard hung there would never have run in production ++ * while a test that constructed the layer itself passed happily. The assertion ++ * below reads `persistence/Layers/Sqlite.ts` — the file that actually sequences ++ * boot — rather than any layer this test assembles. ++ */ ++ ++import { readFileSync } from "node:fs"; ++import { fileURLToPath } from "node:url"; ++ ++import { ProjectId, ThreadId } from "@t3tools/contracts"; ++import { assert, describe, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; ++import * as Option from "effect/Option"; ++import * as SqlClient from "effect/unstable/sql/SqlClient"; ++ ++import { runMigrations } from "../persistence/Migrations.ts"; ++import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; ++import { ++ ProjectionThreadRepository, ++ type ProjectionThread, ++} from "../persistence/Services/ProjectionThreads.ts"; ++import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; ++import { applyCodevSchemaGuard } from "./schemaGuard.ts"; ++ ++const NOW = "2026-01-01T00:00:00.000Z"; ++ ++/** A fresh database per test. Schema tests that share a schema test the order. */ ++const withDb = ( ++ effect: Effect.Effect, ++) => { ++ const sqlite = NodeSqliteClient.layerMemory(); ++ return Effect.provide( ++ effect, ++ Layer.mergeAll(sqlite, Layer.provide(ProjectionThreadRepositoryLive, sqlite)), ++ ); ++}; ++ ++/** The state of a database the pinned pre-fork server produced: migrated, no guard. */ ++const preForkDatabase = Effect.fn("preForkDatabase")(function* () { ++ yield* runMigrations(); ++}); ++ ++function row( ++ threadId: string, ++ overrides: Partial = {}, ++): ProjectionThread { ++ return { ++ threadId: ThreadId.make(threadId), ++ projectId: ProjectId.make("project-1"), ++ title: `Thread ${threadId}`, ++ modelSelection: { instanceId: "codex", model: "gpt-5.4" }, ++ runtimeMode: "full-access", ++ interactionMode: "default", ++ branch: null, ++ worktreePath: null, ++ latestTurnId: null, ++ createdAt: NOW, ++ updatedAt: NOW, ++ archivedAt: null, ++ settledOverride: null, ++ settledAt: null, ++ snoozedUntil: null, ++ snoozedAt: null, ++ pinnedAt: null, ++ latestUserMessageAt: null, ++ pendingApprovalCount: 0, ++ pendingUserInputCount: 0, ++ hasActionableProposedPlan: 0, ++ deletedAt: null, ++ ...overrides, ++ } as ProjectionThread; ++} ++ ++describe("codev thread hierarchy in persistence (spec 250)", () => { ++ it.effect("round-trips role and parentThreadId through the repository", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* preForkDatabase(); ++ yield* applyCodevSchemaGuard(); ++ const repository = yield* ProjectionThreadRepository; ++ ++ yield* repository.upsert( ++ row("thread-architect", { role: "architect", parentThreadId: null }), ++ ); ++ yield* repository.upsert( ++ row("thread-builder", { ++ role: "builder", ++ parentThreadId: ThreadId.make("thread-architect"), ++ }), ++ ); ++ ++ const architect = yield* repository.getById({ threadId: ThreadId.make("thread-architect") }); ++ const builder = yield* repository.getById({ threadId: ThreadId.make("thread-builder") }); ++ ++ assert.ok(Option.isSome(architect)); ++ assert.ok(Option.isSome(builder)); ++ assert.strictEqual(architect.value.role, "architect"); ++ assert.strictEqual(architect.value.parentThreadId, null); ++ assert.strictEqual(builder.value.role, "builder"); ++ assert.strictEqual(builder.value.parentThreadId, "thread-architect"); ++ }), ++ ), ++ ); ++ ++ /** ++ * Criterion 8, the database half. A row written by the pre-fork server has no ++ * values in the new columns, and the read must report that as "not recorded" ++ * rather than inventing a role. ++ */ ++ it.effect("reads pre-fork rows as not recorded, never as a guessed role", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* preForkDatabase(); ++ ++ // Written the way the pinned server writes it: no Codev columns exist yet. ++ yield* sql` ++ INSERT INTO projection_threads ( ++ thread_id, project_id, title, model_selection_json, runtime_mode, ++ interaction_mode, branch, worktree_path, latest_turn_id, ++ created_at, updated_at, archived_at, settled_override, settled_at, ++ snoozed_until, snoozed_at, pinned_at, latest_user_message_at, ++ pending_approval_count, pending_user_input_count, ++ has_actionable_proposed_plan, deleted_at ++ ) VALUES ( ++ 'thread-legacy', 'project-1', 'Written before the fork', ++ ${JSON.stringify({ instanceId: "codex", model: "gpt-5.4" })}, ++ 'full-access', 'default', NULL, NULL, NULL, ++ ${NOW}, ${NOW}, NULL, NULL, NULL, ++ NULL, NULL, NULL, NULL, ++ 0, 0, 0, NULL ++ ) ++ `; ++ ++ yield* applyCodevSchemaGuard(); ++ ++ const repository = yield* ProjectionThreadRepository; ++ const legacy = yield* repository.getById({ threadId: ThreadId.make("thread-legacy") }); ++ ++ assert.ok(Option.isSome(legacy), "the pre-fork row must still be readable"); ++ assert.strictEqual(legacy.value.role, null); ++ assert.strictEqual(legacy.value.parentThreadId, null); ++ assert.strictEqual(legacy.value.title, "Written before the fork"); ++ }), ++ ), ++ ); ++ ++ it.effect("a half-applied schema still reads, and the next start completes it", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* preForkDatabase(); ++ ++ // The state a kill between the two ALTERs leaves. ++ yield* sql`ALTER TABLE projection_threads ADD COLUMN codev_role TEXT`; ++ ++ const finished = yield* applyCodevSchemaGuard(); ++ assert.deepStrictEqual([...finished.added], ["codev_parent_thread_id"]); ++ ++ const repository = yield* ProjectionThreadRepository; ++ yield* repository.upsert(row("thread-after-crash", { role: "builder" })); ++ const found = yield* repository.getById({ ++ threadId: ThreadId.make("thread-after-crash"), ++ }); ++ assert.ok(Option.isSome(found)); ++ assert.strictEqual(found.value.role, "builder"); ++ }), ++ ), ++ ); ++ ++ it.effect("lists threads for a project with their hierarchy intact", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* preForkDatabase(); ++ yield* applyCodevSchemaGuard(); ++ const repository = yield* ProjectionThreadRepository; ++ ++ yield* repository.upsert(row("thread-a", { role: "architect" })); ++ yield* repository.upsert( ++ row("thread-b", { ++ role: "builder", ++ parentThreadId: ThreadId.make("thread-a"), ++ }), ++ ); ++ yield* repository.upsert(row("thread-c")); ++ ++ const threads = yield* repository.listByProjectId({ ++ projectId: ProjectId.make("project-1"), ++ }); ++ const roles = new Map(threads.map((thread) => [thread.threadId as string, thread.role])); ++ ++ assert.strictEqual(roles.get("thread-a"), "architect"); ++ assert.strictEqual(roles.get("thread-b"), "builder"); ++ assert.strictEqual(roles.get("thread-c"), null, "an unset role reads as null, not absent"); ++ }), ++ ), ++ ); ++}); ++ ++describe("codev schema guard start-up ordering (spec 250)", () => { ++ const sqliteLayerSource = readFileSync( ++ fileURLToPath(new URL("../persistence/Layers/Sqlite.ts", import.meta.url)), ++ "utf8", ++ ); ++ ++ /** ++ * Asserted against the production file, not against a layer this test builds. ++ * A test that assembles the ordering itself proves the ordering is possible, ++ * never that production uses it. ++ */ ++ it("runs the guard after the migrator in the file that actually boots the database", () => { ++ const migrations = sqliteLayerSource.indexOf("yield* runMigrations()"); ++ const guard = sqliteLayerSource.indexOf("yield* codevSchemaGuardStep()"); ++ ++ assert.ok(migrations >= 0, "Sqlite.ts must still run the migrator"); ++ assert.ok(guard >= 0, "Sqlite.ts must run the Codev schema guard"); ++ assert.ok( ++ migrations < guard, ++ "the guard must run AFTER the migrator: a projection query before it would read " + ++ "projection_threads without the Codev columns and fail as missing data", ++ ); ++ }); ++ ++ it("wires the guard into the one setup both the file-backed and memory layers share", () => { ++ // `makeSqlitePersistenceLive` and `SqlitePersistenceMemory` both provide ++ // `setup`. If the guard ever moves out of `setup` into only one of them, the ++ // other silently boots without the columns. ++ const setupStart = sqliteLayerSource.indexOf("const setup = Layer.effectDiscard("); ++ const setupEnd = sqliteLayerSource.indexOf("export const makeSqlitePersistenceLive"); ++ assert.ok(setupStart >= 0 && setupEnd > setupStart); ++ ++ const setupBody = sqliteLayerSource.slice(setupStart, setupEnd); ++ assert.ok( ++ setupBody.includes("codevSchemaGuardStep()"), ++ "the guard must live in `setup`, which both persistence layers provide", ++ ); ++ }); ++ ++ it("keeps the guard out of upstream's migration registry", () => { ++ const migrationsSource = readFileSync( ++ fileURLToPath(new URL("../persistence/Migrations.ts", import.meta.url)), ++ "utf8", ++ ); ++ assert.ok( ++ !migrationsSource.includes("codev") && !migrationsSource.includes("Codev"), ++ "Codev must not appear in migrationEntries: a watermark migrator would skip every " + ++ "later upstream migration", ++ ); ++ }); ++}); +diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +index 8eb8cdb56..cd53f674e 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +@@ -635,6 +635,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + linkedPullRequest: null, ++ // Codev customization (spec 250). From the event, not null — see the ++ // matching note in projector.ts. ++ role: event.payload.role, ++ parentThreadId: event.payload.parentThreadId, + latestTurnId: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, +diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +index e0fc1e901..a94bc8050 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +@@ -333,6 +333,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + snoozedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", ++ // Codev customization (spec 250). The read path normalizes with ++ // `?? null`, so a row with no recorded hierarchy hydrates as an ++ // explicit null rather than an absent key. ++ role: null, ++ parentThreadId: null, + titleRegeneration: null, + deletedAt: null, + messages: [ +@@ -458,6 +463,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + snoozedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", ++ // Codev customization (spec 250). See the note on the thread literal above. ++ role: null, ++ parentThreadId: null, + titleRegeneration: null, + session: { + threadId: ThreadId.make("thread-1"), +diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +index bebaf7686..31e6db929 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +@@ -425,6 +425,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -462,6 +464,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -501,6 +505,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -944,6 +950,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -1703,6 +1711,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), ++ // Codev customization (spec 250). Passed through as null rather ++ // than omitted: the shell schema decodes an absent field to null ++ // anyway, and being explicit keeps "no role recorded" a value the ++ // client receives instead of a key it has to infer. ++ role: row.role ?? null, ++ parentThreadId: row.parentThreadId ?? null, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -1913,6 +1927,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), ++ role: row.role ?? null, ++ parentThreadId: row.parentThreadId ?? null, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2052,6 +2068,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), ++ role: row.role ?? null, ++ parentThreadId: row.parentThreadId ?? null, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2200,6 +2218,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), ++ role: row.role ?? null, ++ parentThreadId: row.parentThreadId ?? null, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2482,6 +2502,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), ++ role: threadRow.value.role ?? null, ++ parentThreadId: threadRow.value.parentThreadId ?? null, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, +@@ -2626,6 +2648,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), ++ role: threadRow.value.role ?? null, ++ parentThreadId: threadRow.value.parentThreadId ?? null, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, +diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts +index fea36b571..5f87e12ca 100644 +--- a/apps/server/src/orchestration/decider.delete.test.ts ++++ b/apps/server/src/orchestration/decider.delete.test.ts +@@ -68,6 +68,12 @@ const seedReadModel = Effect.gen(function* () { + runtimeMode: "approval-required", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). Explicit nulls because ++ // `ThreadCreatedPayload` keeps a decoding default rather than being ++ // optional; absence is exercised deliberately in ++ // apps/server/src/codev/threadHierarchy.test.ts, not here. ++ role: null, ++ parentThreadId: null, + createdAt: now, + updatedAt: now, + }, +@@ -96,6 +102,12 @@ const seedReadModel = Effect.gen(function* () { + runtimeMode: "approval-required", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). Explicit nulls because ++ // `ThreadCreatedPayload` keeps a decoding default rather than being ++ // optional; absence is exercised deliberately in ++ // apps/server/src/codev/threadHierarchy.test.ts, not here. ++ role: null, ++ parentThreadId: null, + createdAt: now, + updatedAt: now, + }, +diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts +index bf5c509fa..36b013571 100644 +--- a/apps/server/src/orchestration/decider.projectScripts.test.ts ++++ b/apps/server/src/orchestration/decider.projectScripts.test.ts +@@ -294,6 +294,12 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { + runtimeMode: "approval-required", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). Explicit nulls because ++ // `ThreadCreatedPayload` keeps a decoding default rather than being ++ // optional; absence is exercised deliberately in ++ // apps/server/src/codev/threadHierarchy.test.ts, not here. ++ role: null, ++ parentThreadId: null, + createdAt: now, + updatedAt: now, + }, +@@ -391,6 +397,12 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { + runtimeMode: "full-access", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). Explicit nulls because ++ // `ThreadCreatedPayload` keeps a decoding default rather than being ++ // optional; absence is exercised deliberately in ++ // apps/server/src/codev/threadHierarchy.test.ts, not here. ++ role: null, ++ parentThreadId: null, + createdAt: now, + updatedAt: now, + }, +@@ -469,6 +481,12 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { + runtimeMode: "approval-required", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). Explicit nulls because ++ // `ThreadCreatedPayload` keeps a decoding default rather than being ++ // optional; absence is exercised deliberately in ++ // apps/server/src/codev/threadHierarchy.test.ts, not here. ++ role: null, ++ parentThreadId: null, + createdAt: now, + updatedAt: now, + }, +diff --git a/apps/server/src/orchestration/projector.codevHierarchy.test.ts b/apps/server/src/orchestration/projector.codevHierarchy.test.ts +new file mode 100644 +index 000000000..2873e8f22 +--- /dev/null ++++ b/apps/server/src/orchestration/projector.codevHierarchy.test.ts +@@ -0,0 +1,194 @@ ++/** ++ * Codev customization (spec 250) — thread hierarchy through the projector. ++ * ++ * Criterion 8's rebuild half: a projection rebuilt from a PRE-FORK event log ++ * decodes every historical `thread.created`, and the added fields read as `null` ++ * ("not recorded") rather than as a guessed role. ++ * ++ * The interesting failure this guards is not a crash. It is a rebuild that ++ * silently flattens the hierarchy while every thread count still matches — which ++ * is what a hardcoded `role: null` in the projector would produce, and what a ++ * test asserting only "12 threads in, 12 threads out" would miss. ++ */ ++ ++import { ++ CommandId, ++ EventId, ++ ProjectId, ++ ThreadId, ++ type OrchestrationEvent, ++} from "@t3tools/contracts"; ++import * as Effect from "effect/Effect"; ++import { describe, expect, it } from "vite-plus/test"; ++ ++import { createEmptyReadModel, projectEvent } from "./projector.ts"; ++ ++const NOW = "2026-01-01T00:00:00.000Z"; ++ ++function makeEvent(input: { ++ sequence: number; ++ type: OrchestrationEvent["type"]; ++ aggregateKind: OrchestrationEvent["aggregateKind"]; ++ aggregateId: string; ++ payload: unknown; ++}): OrchestrationEvent { ++ return { ++ sequence: input.sequence, ++ eventId: EventId.make(`event-${input.sequence}`), ++ type: input.type, ++ aggregateKind: input.aggregateKind, ++ aggregateId: ++ input.aggregateKind === "project" ++ ? ProjectId.make(input.aggregateId) ++ : ThreadId.make(input.aggregateId), ++ occurredAt: NOW, ++ commandId: CommandId.make(`cmd-${input.sequence}`), ++ causationEventId: null, ++ correlationId: null, ++ metadata: {}, ++ payload: input.payload as never, ++ } as OrchestrationEvent; ++} ++ ++const projectCreated = makeEvent({ ++ sequence: 1, ++ type: "project.created", ++ aggregateKind: "project", ++ aggregateId: "project-1", ++ payload: { ++ projectId: "project-1", ++ title: "Project", ++ workspaceRoot: "/tmp/project-1", ++ defaultModelSelection: null, ++ scripts: [], ++ createdAt: NOW, ++ updatedAt: NOW, ++ }, ++}); ++ ++/** A `thread.created` payload exactly as the log held it BEFORE the fork. */ ++function preForkThreadCreated(sequence: number, threadId: string): OrchestrationEvent { ++ return makeEvent({ ++ sequence, ++ type: "thread.created", ++ aggregateKind: "thread", ++ aggregateId: threadId, ++ payload: { ++ threadId, ++ projectId: "project-1", ++ title: `Thread ${threadId}`, ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ createdAt: NOW, ++ updatedAt: NOW, ++ }, ++ }); ++} ++ ++/** A `thread.created` payload written by Codev, carrying the hierarchy. */ ++function codevThreadCreated( ++ sequence: number, ++ threadId: string, ++ role: "architect" | "builder", ++ parentThreadId: string | null, ++): OrchestrationEvent { ++ return makeEvent({ ++ sequence, ++ type: "thread.created", ++ aggregateKind: "thread", ++ aggregateId: threadId, ++ payload: { ++ threadId, ++ projectId: "project-1", ++ title: `Thread ${threadId}`, ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ role, ++ parentThreadId, ++ createdAt: NOW, ++ updatedAt: NOW, ++ }, ++ }); ++} ++ ++/** Fold the log the way a real rebuild does: one event at a time, in order. */ ++async function rebuild(events: ReadonlyArray) { ++ let model = createEmptyReadModel(NOW); ++ for (const event of events) { ++ model = await Effect.runPromise(projectEvent(model, event)); ++ } ++ return model; ++} ++ ++describe("projector: codev thread hierarchy (spec 250)", () => { ++ it("rebuilds a pre-fork event log, reading the hierarchy as not recorded", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ preForkThreadCreated(3, "thread-b"), ++ preForkThreadCreated(4, "thread-c"), ++ ]); ++ ++ expect(model.threads).toHaveLength(3); ++ for (const thread of model.threads) { ++ // null, not undefined and not a guessed role. "Codev did not create this." ++ expect(thread.role).toBeNull(); ++ expect(thread.parentThreadId).toBeNull(); ++ } ++ }); ++ ++ it("reproduces the hierarchy on rebuild rather than flattening it", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ codevThreadCreated(2, "thread-architect", "architect", null), ++ codevThreadCreated(3, "thread-builder-1", "builder", "thread-architect"), ++ codevThreadCreated(4, "thread-builder-2", "builder", "thread-architect"), ++ ]); ++ ++ const byId = new Map(model.threads.map((thread) => [thread.id as string, thread])); ++ ++ expect(byId.get("thread-architect")?.role).toBe("architect"); ++ expect(byId.get("thread-architect")?.parentThreadId).toBeNull(); ++ ++ for (const builder of ["thread-builder-1", "thread-builder-2"]) { ++ expect(byId.get(builder)?.role).toBe("builder"); ++ expect(byId.get(builder)?.parentThreadId).toBe("thread-architect"); ++ } ++ }); ++ ++ it("keeps both kinds of thread distinguishable in one log", async () => { ++ // The mixed case is the real one: a database that existed before the fork and ++ // then gained Codev threads. A rebuild must not level them. ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-legacy"), ++ codevThreadCreated(3, "thread-architect", "architect", null), ++ codevThreadCreated(4, "thread-builder", "builder", "thread-architect"), ++ ]); ++ ++ const roles = new Map(model.threads.map((thread) => [thread.id as string, thread.role])); ++ expect(roles.get("thread-legacy")).toBeNull(); ++ expect(roles.get("thread-architect")).toBe("architect"); ++ expect(roles.get("thread-builder")).toBe("builder"); ++ }); ++ ++ it("survives a second rebuild over the same log, unchanged", async () => { ++ // A rebuild is re-run after a schema change; a projector that read the fields ++ // once and dropped them on replay would show up here and nowhere else. ++ const events = [ ++ projectCreated, ++ preForkThreadCreated(2, "thread-legacy"), ++ codevThreadCreated(3, "thread-architect", "architect", null), ++ codevThreadCreated(4, "thread-builder", "builder", "thread-architect"), ++ ]; ++ ++ const first = await rebuild(events); ++ const second = await rebuild(events); ++ ++ expect(second.threads.map((thread) => [thread.id, thread.role, thread.parentThreadId])).toEqual( ++ first.threads.map((thread) => [thread.id, thread.role, thread.parentThreadId]), ++ ); ++ }); ++}); +diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts +index 9c07a3120..50697014d 100644 +--- a/apps/server/src/orchestration/projector.test.ts ++++ b/apps/server/src/orchestration/projector.test.ts +@@ -85,6 +85,10 @@ describe("orchestration projector", () => { + interactionMode: "default", + branch: null, + worktreePath: null, ++ // Codev customization (spec 250). A pre-fork `thread.created` payload ++ // defaults both to null, which is what "Codev did not create this" means. ++ role: null, ++ parentThreadId: null, + latestTurn: null, + createdAt: now, + updatedAt: now, +diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts +index e59ca8281..c1684c08b 100644 +--- a/apps/server/src/orchestration/projector.ts ++++ b/apps/server/src/orchestration/projector.ts +@@ -297,6 +297,12 @@ export function projectEvent( + interactionMode: payload.interactionMode, + branch: payload.branch, + worktreePath: payload.worktreePath, ++ // Codev customization (spec 250). Carried from the event rather than ++ // hardcoded null: a rebuild over a log that already contains Codev ++ // threads has to reproduce their hierarchy, and a null here would ++ // silently flatten it while every count still matched. ++ role: payload.role, ++ parentThreadId: payload.parentThreadId, + latestTurn: null, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, +diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts +index e19c46146..26bb71f1f 100644 +--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts ++++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts +@@ -41,6 +41,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + branch, + worktree_path, + linked_pull_request_json, ++ codev_role, ++ codev_parent_thread_id, + latest_turn_id, + created_at, + updated_at, +@@ -69,6 +71,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + ${row.branch}, + ${row.worktreePath}, + ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ++ ${row.role ?? null}, ++ ${row.parentThreadId ?? null}, + ${row.latestTurnId}, + ${row.createdAt}, + ${row.updatedAt}, +@@ -97,6 +101,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + branch = excluded.branch, + worktree_path = excluded.worktree_path, + linked_pull_request_json = excluded.linked_pull_request_json, ++ codev_role = excluded.codev_role, ++ codev_parent_thread_id = excluded.codev_parent_thread_id, + latest_turn_id = excluded.latest_turn_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, +@@ -132,6 +138,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -169,6 +177,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + branch, + worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", ++ codev_role AS "role", ++ codev_parent_thread_id AS "parentThreadId", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts +index ec1ffdefa..3a3abc121 100644 +--- a/apps/server/src/persistence/Layers/Sqlite.ts ++++ b/apps/server/src/persistence/Layers/Sqlite.ts +@@ -6,6 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; + import type { SqlError } from "effect/unstable/sql/SqlError"; + + import { runMigrations } from "../Migrations.ts"; ++import { codevSchemaGuardStep } from "../../codev/schemaGuard.ts"; + import { ServerConfig } from "../../config.ts"; + + type RuntimeSqliteLayerConfig = { +@@ -38,6 +39,13 @@ const setup = Layer.effectDiscard( + yield* sql`PRAGMA foreign_keys = ON;`; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* runMigrations(); ++ // Codev customization (spec 250). AFTER upstream's migrator and before any ++ // projection layer opens, because a query that ran first would read ++ // `projection_threads` without our columns and fail as missing data rather ++ // than as a boot-order bug. Deliberately not a numbered migration — see ++ // apps/server/src/codev/schemaGuard.ts for what a watermark migrator does to ++ // an out-of-band id. ++ yield* codevSchemaGuardStep(); + }), + ); + +diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts +index 75a9a11d4..fab83e90e 100644 +--- a/apps/server/src/persistence/Services/ProjectionThreads.ts ++++ b/apps/server/src/persistence/Services/ProjectionThreads.ts +@@ -15,6 +15,7 @@ import { + ProviderInteractionMode, + RuntimeMode, + ThreadLinkedPullRequest, ++ CodevThreadRole, + ThreadId, + TurnId, + } from "@t3tools/contracts"; +@@ -35,6 +36,14 @@ export const ProjectionThread = Schema.Struct({ + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), ++ /** ++ * Codev customization (spec 250). Optional, not merely nullable: a row read ++ * from a database whose guard has not run yet has no such column at all, and ++ * `optional` is the difference between that decoding as "not recorded" and ++ * failing the whole projection read. ++ */ ++ role: Schema.optional(Schema.NullOr(CodevThreadRole)), ++ parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts +index 52b893f39..0ee8fd20e 100644 +--- a/packages/contracts/src/orchestration.test.ts ++++ b/packages/contracts/src/orchestration.test.ts +@@ -1021,3 +1021,158 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); + }); ++ ++// --------------------------------------------------------------------------- ++// Codev customization (spec 250) — thread hierarchy. ++// ++// The two fields are spelled differently on purpose and these tests pin the ++// difference. `ThreadCreatedPayload` carries a DECODING DEFAULT because the event ++// log is full of payloads written before the fields existed and a projection ++// rebuild has to replay every one of them; the projector reads `payload.role` ++// unconditionally, so that read has to be total. The read models ++// (`OrchestrationThread`, `OrchestrationThreadShell`) are OPTIONAL instead, ++// matching `linkedPullRequest` — upstream's own newest field, made optional so ++// cached snapshots from older servers still decode. ++ ++it.effect("decodes a pre-fork thread.created payload, defaulting the hierarchy to null", () => ++ Effect.gen(function* () { ++ // Byte-for-byte the shape already in the event log: no role, no parent. ++ const payload = yield* decodeThreadCreatedPayload({ ++ threadId: "thread-legacy", ++ projectId: "project-legacy", ++ title: "Written before the fork existed", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ }); ++ ++ // Defaulted, not absent. The projector reads these without checking. ++ assert.strictEqual(payload.role, null); ++ assert.strictEqual(payload.parentThreadId, null); ++ }), ++); ++ ++it.effect("decodes a Codev thread.created payload carrying role and parent", () => ++ Effect.gen(function* () { ++ const payload = yield* decodeThreadCreatedPayload({ ++ threadId: "thread-builder", ++ projectId: "project-1", ++ title: "spir-250", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ role: "builder", ++ parentThreadId: "thread-architect", ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ }); ++ ++ assert.strictEqual(payload.role, "builder"); ++ assert.strictEqual(payload.parentThreadId, "thread-architect"); ++ }), ++); ++ ++it.effect("rejects a role outside the architect/builder union", () => ++ Effect.gen(function* () { ++ // "unknown" would be a third spelling of null. There are two roles. ++ const result = yield* Effect.result( ++ decodeThreadCreatedPayload({ ++ threadId: "thread-bad-role", ++ projectId: "project-1", ++ title: "Bad role", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ role: "unknown", ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ }), ++ ); ++ ++ assert.strictEqual(result._tag, "Failure"); ++ }), ++); ++ ++it.effect("round-trips role and parentThreadId through encode", () => ++ Effect.gen(function* () { ++ const decoded = yield* decodeThreadCreatedPayload({ ++ threadId: "thread-rt", ++ projectId: "project-1", ++ title: "Round trip", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ branch: null, ++ worktreePath: null, ++ role: "architect", ++ parentThreadId: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ }); ++ ++ const encoded = yield* encodeThreadCreatedPayload(decoded); ++ assert.strictEqual(encoded.role, "architect"); ++ assert.strictEqual(encoded.parentThreadId, null); ++ }), ++); ++ ++it.effect("decodes thread and shell with the hierarchy present and absent", () => ++ Effect.gen(function* () { ++ const common = { ++ id: "thread-hierarchy", ++ projectId: "project-1", ++ title: "Hierarchy", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ runtimeMode: DEFAULT_RUNTIME_MODE, ++ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ++ branch: null, ++ worktreePath: null, ++ latestTurn: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ archivedAt: null, ++ session: null, ++ }; ++ const shellExtras = { ++ latestUserMessageAt: null, ++ hasPendingApprovals: false, ++ hasPendingUserInput: false, ++ hasActionableProposedPlan: false, ++ }; ++ const threadExtras = { ++ deletedAt: null, ++ messages: [], ++ proposedPlans: [], ++ activities: [], ++ checkpoints: [], ++ }; ++ ++ const withHierarchy = yield* decodeOrchestrationThreadShell({ ++ ...common, ++ ...shellExtras, ++ role: "builder", ++ parentThreadId: "thread-architect", ++ }); ++ assert.strictEqual(withHierarchy.role, "builder"); ++ assert.strictEqual(withHierarchy.parentThreadId, "thread-architect"); ++ ++ // A cached snapshot from a server that predates the fork. It must decode. ++ const withoutHierarchy = yield* decodeOrchestrationThreadShell({ ++ ...common, ++ ...shellExtras, ++ }); ++ assert.strictEqual(withoutHierarchy.role, undefined); ++ assert.strictEqual(withoutHierarchy.parentThreadId, undefined); ++ ++ // An explicit null is the shape the server actually emits: every read path ++ // normalizes with `?? null`, so clients see one spelling in practice. ++ const explicitNull = yield* decodeOrchestrationThread({ ++ ...common, ++ ...threadExtras, ++ role: null, ++ parentThreadId: null, ++ }); ++ assert.strictEqual(explicitNull.role, null); ++ assert.strictEqual(explicitNull.parentThreadId, null); ++ }), ++); +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index e0634cea1..5f67b4a3d 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -395,6 +395,16 @@ export const ThreadLinkedPullRequest = Schema.Struct({ + }); + export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; + ++/** ++ * Codev customization (spec 250). Which side of the Codev hierarchy a thread is. ++ * ++ * `null` — carried by the fields below rather than by this union — means a thread ++ * Codev did not create. Upstream t3code threads are the common case and they have ++ * no role; inventing one for them would turn "we do not know" into a claim. ++ */ ++export const CodevThreadRole = Schema.Literals(["architect", "builder"]); ++export type CodevThreadRole = typeof CodevThreadRole.Type; ++ + export const OrchestrationThread = Schema.Struct({ + id: ThreadId, + projectId: ProjectId, +@@ -408,6 +418,22 @@ export const OrchestrationThread = Schema.Struct({ + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), ++ /** ++ * Codev customization (spec 250). `null` means a thread Codev did not create, ++ * which is every upstream thread and every thread predating the fork. ++ * ++ * `optional`, matching `linkedPullRequest` directly above — upstream's own ++ * newest field, made optional for exactly this reason: cached snapshots from ++ * older servers still decode. A decoding default would have been stricter on ++ * the wire and cost 32 edits across 11 upstream test files, which is the ++ * divergence this fork is explicitly shaped to avoid. ++ * ++ * The server never emits `undefined` for these: every read path normalizes ++ * with `?? null` (see `ProjectionSnapshotQuery.ts`), so one spelling reaches ++ * clients in practice while old payloads still decode. ++ */ ++ role: Schema.optional(Schema.NullOr(CodevThreadRole)), ++ parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), +@@ -479,6 +505,22 @@ export const OrchestrationThreadShell = Schema.Struct({ + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), ++ /** ++ * Codev customization (spec 250). `null` means a thread Codev did not create, ++ * which is every upstream thread and every thread predating the fork. ++ * ++ * `optional`, matching `linkedPullRequest` directly above — upstream's own ++ * newest field, made optional for exactly this reason: cached snapshots from ++ * older servers still decode. A decoding default would have been stricter on ++ * the wire and cost 32 edits across 11 upstream test files, which is the ++ * divergence this fork is explicitly shaped to avoid. ++ * ++ * The server never emits `undefined` for these: every read path normalizes ++ * with `?? null` (see `ProjectionSnapshotQuery.ts`), so one spelling reaches ++ * clients in practice while old payloads still decode. ++ */ ++ role: Schema.optional(Schema.NullOr(CodevThreadRole)), ++ parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), +@@ -1156,6 +1198,19 @@ export const ThreadCreatedPayload = Schema.Struct({ + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), ++ /** ++ * Codev customization (spec 250). `null` means a thread Codev did not create, ++ * which is every upstream thread and every thread predating the fork. ++ * ++ * A decoding DEFAULT here, not `optional` as on the read models: the event log ++ * is full of `thread.created` payloads written before these fields existed, a ++ * rebuild has to replay every one of them, and the projector reads ++ * `payload.role` unconditionally. Defaulting makes that read total. A schema ++ * that rejected the old payloads would make the projection unrebuildable. ++ */ ++ role: Schema.NullOr(CodevThreadRole).pipe(Schema.withDecodingDefault(Effect.succeed(null))), ++ /** Codev customization (spec 250). The architect thread that owns this builder. */ ++ parentThreadId: Schema.NullOr(ThreadId).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + }); diff --git a/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch b/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch new file mode 100644 index 000000000..ee1944005 --- /dev/null +++ b/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch @@ -0,0 +1,149 @@ +From 992b781f4314ec1df1abb752c7c9c5378ec13c26 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 16:15:14 -0600 +Subject: [PATCH 02/34] [Spec 250][Phase: phase_2] test: prove the migrator + runs, not that SQLite accepts a column +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Both review lanes caught the same substitution. The "schema work landing +after the guard still takes effect" test did a raw ALTER TABLE, which +proves SQLite accepts another column and says nothing about whether the +watermark let the MIGRATOR execute one — the entire question migration 900 +got wrong. + +Now runMigrations({toMigrationInclusive: 41}) -> guard -> runMigrations(), +asserting 42 actually executed and its column exists. That is upstream's +own idiom from 042_ProjectionThreadLinkedPullRequest.test.ts:16-17. Adds a +second test that the guard writes no rows to effect_sql_migrations at all. + +apps/server/scripts/apply-codev-guard.ts runs the production +codevSchemaGuardStep against a file-backed database, for criterion 8b's +resume step. It calls the real guard rather than reimplementing its +statements: a script with its own ALTERs would prove the statements work +and nothing about the guard. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/scripts/apply-codev-guard.ts | 40 ++++++++++++++++++++ + apps/server/src/codev/schemaGuard.test.ts | 45 ++++++++++++++++++----- + 2 files changed, 75 insertions(+), 10 deletions(-) + create mode 100644 apps/server/scripts/apply-codev-guard.ts + +diff --git a/apps/server/scripts/apply-codev-guard.ts b/apps/server/scripts/apply-codev-guard.ts +new file mode 100644 +index 000000000..9718a7438 +--- /dev/null ++++ b/apps/server/scripts/apply-codev-guard.ts +@@ -0,0 +1,40 @@ ++/** ++ * Codev customization (spec 250) — run the schema guard against a file-backed ++ * database, out of band. ++ * ++ * Exists for criterion 8b, which has to resume a half-applied schema on a REAL ++ * file after a REAL process kill. The in-memory unit test proves the guard's ++ * logic; this proves the same code completes the job on the artefact a crash ++ * actually left behind. ++ * ++ * It runs the production `codevSchemaGuardStep`, not a copy of it. A script with ++ * its own ALTER statements would prove the statements work and nothing about the ++ * guard. ++ * ++ * Usage: node apps/server/scripts/apply-codev-guard.ts ++ */ ++ ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; ++ ++import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; ++import { applyCodevSchemaGuard } from "../src/codev/schemaGuard.ts"; ++ ++const dbPath = process.argv[2]; ++if (dbPath === undefined) { ++ console.error("usage: apply-codev-guard.ts "); ++ process.exit(2); ++} ++ ++const program = Effect.gen(function* () { ++ const result = yield* applyCodevSchemaGuard(); ++ // stdout is the machine-readable answer; the guard's own log goes to stderr. ++ console.log(JSON.stringify(result)); ++}); ++ ++await Effect.runPromise( ++ Effect.provide(program, Layer.mergeAll(NodeSqliteClient.layer({ filename: dbPath }))), ++).catch((error: unknown) => { ++ console.error(`apply-codev-guard failed: ${String(error)}`); ++ process.exit(1); ++}); +diff --git a/apps/server/src/codev/schemaGuard.test.ts b/apps/server/src/codev/schemaGuard.test.ts +index ad782c66d..27441fd31 100644 +--- a/apps/server/src/codev/schemaGuard.test.ts ++++ b/apps/server/src/codev/schemaGuard.test.ts +@@ -142,30 +142,55 @@ describe("codev schema guard (spec 250)", () => { + * skipped forever with no error. Applying the columns outside the registry + * means the watermark never moves, so later schema work still lands. + */ +- it.effect("schema work landing after the guard still takes effect", () => ++ it.effect("an upstream migration still RUNS after the guard has applied", () => + withDb( + Effect.gen(function* () { +- const sql = yield* SqlClient.SqlClient; +- yield* runMigrations(); ++ // Through the MIGRATOR, not a raw ALTER TABLE. A raw statement proves ++ // SQLite accepts another column; it says nothing about whether the ++ // watermark let the migrator execute one, which is the entire question ++ // migration 900 got wrong. This is upstream's own idiom, from ++ // 042_ProjectionThreadLinkedPullRequest.test.ts:16-17. ++ yield* runMigrations({ toMigrationInclusive: 41 }); ++ const before = yield* migrationWatermark(); ++ assert.ok( ++ !before.some((entry) => entry.startsWith("42_")), ++ "migration 42 must not have run yet, or this test proves nothing", ++ ); ++ + yield* applyCodevSchemaGuard(); + +- const watermarkAfterGuard = yield* migrationWatermark(); ++ // Upstream's next migration, run the way the migrator runs it. ++ yield* runMigrations(); + +- // Stand-in for upstream's next migration. +- yield* sql`ALTER TABLE projection_threads ADD COLUMN pretend_upstream_column TEXT`; ++ const after = yield* migrationWatermark(); ++ assert.ok( ++ after.some((entry) => entry.startsWith("42_")), ++ "migration 42 must execute after the guard: a watermark pushed past it would " + ++ "skip every future upstream migration, silently and forever", ++ ); + + const columns = yield* columnNames(); + assert.ok( +- columns.includes("pretend_upstream_column"), +- "an upstream migration landing after the guard must still take effect", ++ columns.includes("linked_pull_request_json"), ++ "migration 42's column must be present, not merely recorded as run", + ); + for (const column of CODEV_THREAD_COLUMN_NAMES) { + assert.ok(columns.includes(column), "and it must not have displaced Codev's columns"); + } ++ }), ++ ), ++ ); ++ ++ it.effect("the guard adds no rows to the watermark table at all", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* runMigrations({ toMigrationInclusive: 41 }); ++ const before = yield* migrationWatermark(); ++ yield* applyCodevSchemaGuard(); + assert.deepStrictEqual( + yield* migrationWatermark(), +- watermarkAfterGuard, +- "the guard left the watermark free for upstream to keep using", ++ before, ++ "the guard must leave every id above 41 free for upstream", + ); + }), + ), diff --git a/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch b/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch new file mode 100644 index 000000000..9285919b2 --- /dev/null +++ b/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch @@ -0,0 +1,142 @@ +From e1a858434a8096d7a82e05347f8159d94f42c0b1 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 16:36:33 -0600 +Subject: [PATCH 03/34] [Spec 250][Phase: phase_2] test: pin the two guard log + signals + +Review finding: the two signals ARE the mitigation for staying out of the +migration registry, and nothing enforced that they exist, stay two, or +that the applied one names the columns. A rename or a merge into one line +would have broken the deal silently while every other test stayed green. + +apply-codev-guard.ts now calls codevSchemaGuardStep, which is what its +docstring already claimed and what production calls. The bare +applyCodevSchemaGuard left the signal unexercised by the one test that +runs the guard against a real file. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/scripts/apply-codev-guard.ts | 9 ++- + apps/server/src/codev/schemaGuard.test.ts | 68 +++++++++++++++++++++++ + 2 files changed, 75 insertions(+), 2 deletions(-) + +diff --git a/apps/server/scripts/apply-codev-guard.ts b/apps/server/scripts/apply-codev-guard.ts +index 9718a7438..038b41023 100644 +--- a/apps/server/scripts/apply-codev-guard.ts ++++ b/apps/server/scripts/apply-codev-guard.ts +@@ -18,7 +18,7 @@ import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; + + import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +-import { applyCodevSchemaGuard } from "../src/codev/schemaGuard.ts"; ++import { codevSchemaGuardStep } from "../src/codev/schemaGuard.ts"; + + const dbPath = process.argv[2]; + if (dbPath === undefined) { +@@ -27,7 +27,12 @@ if (dbPath === undefined) { + } + + const program = Effect.gen(function* () { +- const result = yield* applyCodevSchemaGuard(); ++ // `codevSchemaGuardStep`, not the bare `applyCodevSchemaGuard`: the step is ++ // what production calls, and it emits the CODEV_SCHEMA_GUARD_* signal that is ++ // the whole mitigation for staying out of the migration registry. Calling the ++ // bare apply here would have left that signal unexercised by the one test that ++ // runs the guard against a real file. ++ const result = yield* codevSchemaGuardStep(); + // stdout is the machine-readable answer; the guard's own log goes to stderr. + console.log(JSON.stringify(result)); + }); +diff --git a/apps/server/src/codev/schemaGuard.test.ts b/apps/server/src/codev/schemaGuard.test.ts +index 27441fd31..481f4363f 100644 +--- a/apps/server/src/codev/schemaGuard.test.ts ++++ b/apps/server/src/codev/schemaGuard.test.ts +@@ -14,6 +14,8 @@ + import { assert, describe, it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; ++import * as Logger from "effect/Logger"; ++import * as References from "effect/References"; + import * as SqlClient from "effect/unstable/sql/SqlClient"; + + import { runMigrations } from "../persistence/Migrations.ts"; +@@ -22,6 +24,7 @@ import { + CODEV_GUARDED_TABLE, + CODEV_THREAD_COLUMN_NAMES, + applyCodevSchemaGuard, ++ codevSchemaGuardStep, + } from "./schemaGuard.ts"; + + /** +@@ -196,6 +199,71 @@ describe("codev schema guard (spec 250)", () => { + ), + ); + ++ /** ++ * The two log signals ARE the mitigation. ++ * ++ * Staying out of `effect_sql_migrations` has exactly one cost: the columns are ++ * absent from the migration history, so their existence is inferrable only by ++ * reading the schema. `CODEV_SCHEMA_GUARD_APPLIED` and `CODEV_SCHEMA_GUARD_NOOP` ++ * are what pays it. Nothing enforced that they exist, that they stay two, or ++ * that the applied one names the columns — so a rename or a merge into one line ++ * would have broken the deal silently while every other test stayed green. ++ * ++ * Two signals, not one, because "added two columns" and "had nothing to do" are ++ * different facts. A single line covering both is what makes a log useless: you ++ * cannot tell a first start from the thousandth. ++ */ ++ it.effect("emits APPLIED with the columns it added, then NOOP on the next start", () => ++ withDb( ++ Effect.gen(function* () { ++ const lines: Array = []; ++ const capture = Logger.make(({ fiber, message }) => { ++ const annotations = fiber.getRef(References.CurrentLogAnnotations); ++ lines.push( ++ `${String(Array.isArray(message) ? message.join(" ") : message)} ` + ++ JSON.stringify(annotations ?? {}), ++ ); ++ }); ++ ++ yield* runMigrations(); ++ ++ const first = yield* codevSchemaGuardStep().pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: false }))); ++ assert.deepStrictEqual([...first.added], [...CODEV_THREAD_COLUMN_NAMES]); ++ ++ const applied = lines.join("\n"); ++ assert.ok( ++ applied.includes("CODEV_SCHEMA_GUARD_APPLIED"), ++ `first start must emit CODEV_SCHEMA_GUARD_APPLIED; got: ${applied}`, ++ ); ++ assert.ok( ++ !applied.includes("CODEV_SCHEMA_GUARD_NOOP"), ++ "a start that added columns must not also claim it had nothing to do", ++ ); ++ for (const column of CODEV_THREAD_COLUMN_NAMES) { ++ assert.ok( ++ applied.includes(column), ++ `the APPLIED signal must name ${column}; a signal that does not say what it did ` + ++ "leaves the columns inferrable only by reading the schema, which is the cost it exists to pay", ++ ); ++ } ++ ++ lines.length = 0; ++ const second = yield* codevSchemaGuardStep().pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: false }))); ++ assert.deepStrictEqual([...second.added], []); ++ ++ const noop = lines.join("\n"); ++ assert.ok( ++ noop.includes("CODEV_SCHEMA_GUARD_NOOP"), ++ `a start with nothing to do must emit CODEV_SCHEMA_GUARD_NOOP; got: ${noop}`, ++ ); ++ assert.ok( ++ !noop.includes("CODEV_SCHEMA_GUARD_APPLIED"), ++ "a start that added nothing must not claim it applied columns", ++ ); ++ }), ++ ), ++ ); ++ + it.effect("guards the table the rest of the phase reads and writes", () => + withDb( + Effect.gen(function* () { diff --git a/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch b/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch new file mode 100644 index 000000000..bbda7750b --- /dev/null +++ b/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch @@ -0,0 +1,861 @@ +From e1b7f7b04af5aa869a552baa622fc9e526a00bb3 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 16:55:23 -0600 +Subject: [PATCH 04/34] [Spec 250][Phase: phase_3] feat: refuse illegal + hierarchy edges at write time +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The rule is one sentence: the only legal edge is architect -> builder. +Enforced in the decider, where it is written, rather than repaired where +it is read — a reader that reparents an orphan or draws a parentless +builder at the root produces a second correct-looking answer, and then two +places disagree about the tree with nothing to say which is right. + +Six reason discriminants, not one generic error. Five causes a caller must +act on differently: "no such parent" is a retry once the parent lands, +"wrong parent role" is a caller bug, "builder without a parent" is a +missing field. One error for all of them tells a caller that something is +wrong and nothing about what to do, and then gets matched on the message +string, which is worse than no discriminant. + +Ordering is deliberate. parent-is-self is checked before parent-not-found, +because a self-reference is a caller bug whether or not the thread exists +yet and "no such parent" would send someone looking for a thread that is +right in front of them. parent-in-other-project is separate from +parent-not-found for the same reason: the parent DOES exist. + +role and parentThreadId are added to thread.create, optional so every +upstream client keeps dispatching it unchanged — the resulting thread has +no role, which is true. They are set at creation and never after: a role +that can be edited later is a role every reader has to re-check. + +NOT refused: a parent archived or deleted afterwards. Retro-refusal would +make an archive fail because of a thread it does not know about, and make +archiving order-dependent. Those children become orphans — still readable, +still carrying the edge, pointing at an archived parent — which phase 7 +shows as unattributed rather than hiding. Two persistence tests assert an +orphan stays distinguishable from a thread that never had a parent. + +15 decider tests, one per case, asserting the discriminant rather than the +failure. Fork typecheck green; contracts 291 passed, server 2788 passed. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/codev/threadHierarchy.test.ts | 80 ++++ + apps/server/src/orchestration/Errors.ts | 45 ++ + .../src/orchestration/commandInvariants.ts | 140 +++++- + .../decider.codevHierarchy.test.ts | 398 ++++++++++++++++++ + apps/server/src/orchestration/decider.ts | 21 +- + packages/contracts/src/orchestration.ts | 15 + + 6 files changed, 695 insertions(+), 4 deletions(-) + create mode 100644 apps/server/src/orchestration/decider.codevHierarchy.test.ts + +diff --git a/apps/server/src/codev/threadHierarchy.test.ts b/apps/server/src/codev/threadHierarchy.test.ts +index 9b34eb02a..4fe964b3c 100644 +--- a/apps/server/src/codev/threadHierarchy.test.ts ++++ b/apps/server/src/codev/threadHierarchy.test.ts +@@ -208,6 +208,86 @@ describe("codev thread hierarchy in persistence (spec 250)", () => { + ); + }); + ++describe("codev orphans after an archive (spec 250)", () => { ++ /** ++ * The deliberate non-refusal, through real persistence. ++ * ++ * Phase 3 refuses illegal edges at write time but does NOT retro-refuse a ++ * parent archived after the fact. Its children become orphans: still readable, ++ * still carrying the edge, pointing at an archived parent. They are not ++ * dropped, not deleted, and not silently reparented — a reader that reparented ++ * them would be inventing a second correct-looking tree. ++ */ ++ it.effect("an archived architect's builders stay readable and keep their edge", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* preForkDatabase(); ++ yield* applyCodevSchemaGuard(); ++ const repository = yield* ProjectionThreadRepository; ++ ++ yield* repository.upsert(row("thread-architect", { role: "architect" })); ++ yield* repository.upsert( ++ row("thread-builder", { ++ role: "builder", ++ parentThreadId: ThreadId.make("thread-architect"), ++ }), ++ ); ++ ++ // Archive the parent, exactly as the projector would on thread.archived. ++ yield* repository.upsert( ++ row("thread-architect", { role: "architect", archivedAt: NOW }), ++ ); ++ ++ const architect = yield* repository.getById({ ++ threadId: ThreadId.make("thread-architect"), ++ }); ++ const builder = yield* repository.getById({ threadId: ThreadId.make("thread-builder") }); ++ ++ assert.ok(Option.isSome(architect)); ++ assert.ok(Option.isSome(builder), "the builder must survive its parent being archived"); ++ assert.strictEqual(architect.value.archivedAt, NOW); ++ assert.strictEqual( ++ builder.value.parentThreadId, ++ "thread-architect", ++ "the edge is kept: an orphan points at an archived parent, it is not reparented", ++ ); ++ assert.strictEqual(builder.value.role, "builder"); ++ assert.strictEqual(builder.value.archivedAt, null, "archiving a parent must not cascade"); ++ }), ++ ), ++ ); ++ ++ it.effect("an orphan is distinguishable from a thread that never had a parent", () => ++ withDb( ++ Effect.gen(function* () { ++ yield* preForkDatabase(); ++ yield* applyCodevSchemaGuard(); ++ const repository = yield* ProjectionThreadRepository; ++ ++ yield* repository.upsert( ++ row("thread-orphan", { ++ role: "builder", ++ parentThreadId: ThreadId.make("thread-gone"), ++ }), ++ ); ++ yield* repository.upsert(row("thread-rootless")); ++ ++ const threads = yield* repository.listByProjectId({ ++ projectId: ProjectId.make("project-1"), ++ }); ++ const byId = new Map(threads.map((t) => [t.threadId as string, t])); ++ ++ // The orphan still names its parent; the rootless thread names none. ++ // Phase 7 needs that difference to put one in the unattributed group and ++ // leave the other where it belongs. ++ assert.strictEqual(byId.get("thread-orphan")?.parentThreadId, "thread-gone"); ++ assert.strictEqual(byId.get("thread-rootless")?.parentThreadId, null); ++ assert.strictEqual(byId.get("thread-rootless")?.role, null); ++ }), ++ ), ++ ); ++}); ++ + describe("codev schema guard start-up ordering (spec 250)", () => { + const sqliteLayerSource = readFileSync( + fileURLToPath(new URL("../persistence/Layers/Sqlite.ts", import.meta.url)), +diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts +index 7abd56770..36ae8fd5a 100644 +--- a/apps/server/src/orchestration/Errors.ts ++++ b/apps/server/src/orchestration/Errors.ts +@@ -40,6 +40,50 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< + } + } + ++/** ++ * Codev customization (spec 250) — an illegal hierarchy edge, refused at write time. ++ * ++ * The `reason` is a discriminant, not prose. Five causes share one shape and a ++ * caller has to act differently on each: "no such parent" is a retry after the ++ * parent lands, "wrong parent role" is a caller bug, "builder without a parent" ++ * is a missing field. One generic error for five causes tells a caller that ++ * something is wrong and nothing about what to do, so it gets read once and then ++ * matched on the message string, which is worse than no discriminant at all. ++ * ++ * `detail` stays human-readable and is never parsed. ++ */ ++export const CodevHierarchyInvalidReason = Schema.Literals([ ++ /** `parentThreadId` names a thread that does not exist. */ ++ "parent-not-found", ++ /** The parent exists but belongs to a different project. */ ++ "parent-in-other-project", ++ /** The thread names itself as its own parent. */ ++ "parent-is-self", ++ /** The parent exists in this project but is not an architect. */ ++ "parent-not-architect", ++ /** `role: "builder"` with no `parentThreadId`. A builder is owned by definition. */ ++ "builder-without-parent", ++ /** `role: "architect"` or no role, carrying a `parentThreadId`. */ ++ "parent-on-non-builder", ++]); ++export type CodevHierarchyInvalidReason = typeof CodevHierarchyInvalidReason.Type; ++ ++export class CodevHierarchyInvalidError extends Schema.TaggedErrorClass()( ++ "CodevHierarchyInvalidError", ++ { ++ commandType: Schema.String, ++ reason: CodevHierarchyInvalidReason, ++ threadId: Schema.String, ++ parentThreadId: Schema.NullOr(Schema.String), ++ detail: Schema.String, ++ cause: Schema.optional(Schema.Defect()), ++ }, ++) { ++ override get message(): string { ++ return `Codev hierarchy invalid (${this.commandType}, ${this.reason}): ${this.detail}`; ++ } ++} ++ + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( + "OrchestrationCommandPreviouslyRejectedError", + { +@@ -97,6 +141,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< + export type OrchestrationDispatchError = + | ProjectionRepositoryError + | OrchestrationCommandInvariantError ++ | CodevHierarchyInvalidError + | OrchestrationCommandIdConflictError + | OrchestrationCommandPreviouslyRejectedError + | OrchestrationProjectorDecodeError +diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts +index b59ded77f..a88ac3276 100644 +--- a/apps/server/src/orchestration/commandInvariants.ts ++++ b/apps/server/src/orchestration/commandInvariants.ts +@@ -1,4 +1,5 @@ + import type { ++ CodevThreadRole, + OrchestrationCommand, + OrchestrationProject, + OrchestrationReadModel, +@@ -9,7 +10,8 @@ import type { + import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + import * as Effect from "effect/Effect"; + +-import { OrchestrationCommandInvariantError } from "./Errors.ts"; ++import { CodevHierarchyInvalidError, OrchestrationCommandInvariantError } from "./Errors.ts"; ++import type { CodevHierarchyInvalidReason } from "./Errors.ts"; + + function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError { + return new OrchestrationCommandInvariantError({ +@@ -182,3 +184,139 @@ export function requireNonNegativeInteger(input: { + ), + ); + } ++ ++// --------------------------------------------------------------------------- ++// Codev customization (spec 250) — hierarchy integrity, refused at write time. ++// ++// The rule is one sentence: **the only legal edge is architect → builder.** ++// Everything below is that sentence, refused where it is written rather than ++// repaired where it is read. ++// ++// No fallback rendering, deliberately. A reader that "helpfully" reparents an ++// orphan, or draws a builder at the root because its parent is missing, produces ++// a second correct-looking answer — and then two places disagree about the shape ++// of the tree with nothing to say which is right. Refusing at write time means ++// there is only ever one answer on disk. ++// ++// What is NOT refused: a parent archived or deleted AFTER the fact. Retro- ++// refusing would mean an archive could fail because of a thread it does not know ++// about, and would make archiving order-dependent. Those children become orphans, ++// which is a real state the reader must show as unattributed rather than hide. ++ ++/** ++ * The hierarchy fields a command carries. `undefined` and `null` both mean "not ++ * set" — upstream clients omit the keys entirely and Codev sends explicit nulls, ++ * and no rule below distinguishes them. ++ */ ++export type CodevHierarchyInput = { ++ readonly role?: CodevThreadRole | null | undefined; ++ readonly parentThreadId?: ThreadId | null | undefined; ++}; ++ ++function hierarchyError(input: { ++ readonly commandType: string; ++ readonly reason: CodevHierarchyInvalidReason; ++ readonly threadId: ThreadId; ++ readonly parentThreadId: ThreadId | null; ++ readonly detail: string; ++}): CodevHierarchyInvalidError { ++ return new CodevHierarchyInvalidError({ ++ commandType: input.commandType, ++ reason: input.reason, ++ threadId: input.threadId, ++ parentThreadId: input.parentThreadId, ++ detail: input.detail, ++ }); ++} ++ ++/** ++ * Assert a thread's hierarchy fields describe a legal edge. ++ * ++ * Ordered most-specific first so the reason a caller receives is the most ++ * actionable one available. `parent-is-self` is checked before `parent-not-found` ++ * because a self-reference is a caller bug whether or not the thread exists yet, ++ * and reporting "no such parent" for it would send someone looking for a missing ++ * thread that is right in front of them. ++ */ ++export function requireCodevHierarchy(input: { ++ readonly readModel: OrchestrationReadModel; ++ readonly commandType: string; ++ readonly threadId: ThreadId; ++ readonly projectId: ProjectId; ++ readonly hierarchy: CodevHierarchyInput; ++}): Effect.Effect { ++ const role = input.hierarchy.role ?? null; ++ const parentThreadId = input.hierarchy.parentThreadId ?? null; ++ const fail = (reason: CodevHierarchyInvalidReason, detail: string) => ++ Effect.fail( ++ hierarchyError({ ++ commandType: input.commandType, ++ reason, ++ threadId: input.threadId, ++ parentThreadId, ++ detail, ++ }), ++ ); ++ ++ // A thread that is not a builder has no parent. This covers `role: null` ++ // (every upstream thread) and `role: "architect"` in one rule, because the ++ // reason they cannot have a parent is the same: nothing owns them. ++ if (role !== "builder") { ++ if (parentThreadId !== null) { ++ return fail( ++ "parent-on-non-builder", ++ `Thread '${input.threadId}' has role ${role === null ? "null" : `'${role}'`} but names ` + ++ `parent '${parentThreadId}'. Only a builder has a parent; the only legal edge is ` + ++ `architect -> builder.`, ++ ); ++ } ++ return Effect.void; ++ } ++ ++ // From here the thread IS a builder. ++ if (parentThreadId === null) { ++ return fail( ++ "builder-without-parent", ++ `Thread '${input.threadId}' has role 'builder' but no parentThreadId. A builder is owned by ` + ++ `an architect by definition; a parentless builder has no place in the tree to be drawn in.`, ++ ); ++ } ++ ++ if (parentThreadId === input.threadId) { ++ return fail( ++ "parent-is-self", ++ `Thread '${input.threadId}' names itself as its own parent.`, ++ ); ++ } ++ ++ const parent = findThreadById(input.readModel, parentThreadId); ++ if (parent === undefined) { ++ return fail( ++ "parent-not-found", ++ `Thread '${input.threadId}' names parent '${parentThreadId}', which does not exist. If the ` + ++ `parent is still being created, dispatch it first: this is refused rather than deferred ` + ++ `because a dangling edge on disk is indistinguishable from a deleted parent.`, ++ ); ++ } ++ ++ if (parent.projectId !== input.projectId) { ++ return fail( ++ "parent-in-other-project", ++ `Thread '${input.threadId}' is in project '${input.projectId}' but names parent ` + ++ `'${parentThreadId}' in project '${parent.projectId}'. The tree is drawn per project; a ` + ++ `cross-project edge has no position in either.`, ++ ); ++ } ++ ++ if ((parent.role ?? null) !== "architect") { ++ const parentRole = parent.role ?? null; ++ return fail( ++ "parent-not-architect", ++ `Thread '${input.threadId}' names parent '${parentThreadId}', whose role is ` + ++ `${parentRole === null ? "null" : `'${parentRole}'`}. The only legal edge is ` + ++ `architect -> builder; ${parentRole === "builder" ? "a builder cannot own builders" : "a thread Codev did not create cannot own builders"}.`, ++ ); ++ } ++ ++ return Effect.void; ++} +diff --git a/apps/server/src/orchestration/decider.codevHierarchy.test.ts b/apps/server/src/orchestration/decider.codevHierarchy.test.ts +new file mode 100644 +index 000000000..3cc5d3949 +--- /dev/null ++++ b/apps/server/src/orchestration/decider.codevHierarchy.test.ts +@@ -0,0 +1,398 @@ ++/** ++ * Codev customization (spec 250) — hierarchy integrity refused at the DECIDER. ++ * ++ * Criterion 11 says "verified against the decider, not against the UI", and that ++ * is the whole point: a rule enforced only where the tree is drawn is a rule the ++ * API does not have. Every case below dispatches a real command and asserts the ++ * refusal's `reason` discriminant, not merely that it failed — five causes that ++ * all report "invalid" are one cause wearing five names, and a caller cannot act ++ * on them differently. ++ */ ++ ++import { ++ CommandId, ++ ProjectId, ++ ProviderInstanceId, ++ ThreadId, ++ type CodevThreadRole, ++ type OrchestrationReadModel, ++ type OrchestrationThread, ++} from "@t3tools/contracts"; ++import * as NodeServices from "@effect/platform-node/NodeServices"; ++import { assert, expect, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++ ++import { decideOrchestrationCommand } from "./decider.ts"; ++import { CodevHierarchyInvalidError } from "./Errors.ts"; ++ ++const NOW = "2026-01-01T00:00:00.000Z"; ++ ++function thread(input: { ++ readonly id: string; ++ readonly projectId?: string; ++ readonly role?: CodevThreadRole | null; ++ readonly parentThreadId?: string | null; ++ readonly archivedAt?: string | null; ++}): OrchestrationThread { ++ return { ++ id: ThreadId.make(input.id), ++ projectId: ProjectId.make(input.projectId ?? "project-1"), ++ title: `Thread ${input.id}`, ++ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, ++ runtimeMode: "full-access", ++ interactionMode: "default", ++ branch: null, ++ worktreePath: null, ++ role: input.role ?? null, ++ parentThreadId: ++ input.parentThreadId == null ? null : ThreadId.make(input.parentThreadId), ++ latestTurn: null, ++ createdAt: NOW, ++ updatedAt: NOW, ++ archivedAt: input.archivedAt ?? null, ++ settledOverride: null, ++ settledAt: null, ++ snoozedUntil: null, ++ snoozedAt: null, ++ pinnedAt: null, ++ pinOrderKey: null, ++ deletedAt: null, ++ messages: [], ++ proposedPlans: [], ++ activities: [], ++ checkpoints: [], ++ session: null, ++ } as OrchestrationThread; ++} ++ ++function readModel(threads: ReadonlyArray): OrchestrationReadModel { ++ return { ++ snapshotSequence: 0, ++ projects: [ ++ { ++ id: ProjectId.make("project-1"), ++ title: "Project One", ++ workspaceRoot: "/tmp/project-1", ++ defaultModelSelection: null, ++ scripts: [], ++ createdAt: NOW, ++ updatedAt: NOW, ++ deletedAt: null, ++ }, ++ { ++ id: ProjectId.make("project-2"), ++ title: "Project Two", ++ workspaceRoot: "/tmp/project-2", ++ defaultModelSelection: null, ++ scripts: [], ++ createdAt: NOW, ++ updatedAt: NOW, ++ deletedAt: null, ++ }, ++ ], ++ threads, ++ updatedAt: NOW, ++ } as OrchestrationReadModel; ++} ++ ++function createCommand(input: { ++ readonly threadId: string; ++ readonly projectId?: string; ++ readonly role?: CodevThreadRole | null; ++ readonly parentThreadId?: string | null; ++}) { ++ return { ++ type: "thread.create" as const, ++ commandId: CommandId.make(`cmd-${input.threadId}`), ++ threadId: ThreadId.make(input.threadId), ++ projectId: ProjectId.make(input.projectId ?? "project-1"), ++ title: `Thread ${input.threadId}`, ++ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, ++ runtimeMode: "full-access" as const, ++ interactionMode: "default" as const, ++ branch: null, ++ worktreePath: null, ++ ...(input.role === undefined ? {} : { role: input.role }), ++ ...(input.parentThreadId === undefined ++ ? {} ++ : { ++ parentThreadId: ++ input.parentThreadId === null ? null : ThreadId.make(input.parentThreadId), ++ }), ++ createdAt: NOW, ++ }; ++} ++ ++const ARCHITECT = thread({ id: "thread-architect", role: "architect" }); ++const BUILDER = thread({ ++ id: "thread-builder", ++ role: "builder", ++ parentThreadId: "thread-architect", ++}); ++const UPSTREAM = thread({ id: "thread-upstream" }); ++const OTHER_PROJECT_ARCHITECT = thread({ ++ id: "thread-other-architect", ++ projectId: "project-2", ++ role: "architect", ++}); ++ ++/** Dispatch and require a hierarchy refusal carrying a specific reason. */ ++const refusalReason = (command: ReturnType, model: OrchestrationReadModel) => ++ Effect.gen(function* () { ++ const outcome = yield* Effect.result( ++ decideOrchestrationCommand({ command: command as never, readModel: model }), ++ ); ++ assert.strictEqual(outcome._tag, "Failure", "the command must be refused, not accepted"); ++ const error = (outcome as { failure: unknown }).failure; ++ assert.ok( ++ error instanceof CodevHierarchyInvalidError, ++ `expected CodevHierarchyInvalidError, got ${String(error)}`, ++ ); ++ return error; ++ }); ++ ++it.layer(NodeServices.layer)("codev hierarchy refusals (spec 250)", (it) => { ++ it.effect("refuses a builder whose parent does not exist", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ threadId: "thread-new", role: "builder", parentThreadId: "thread-absent" }), ++ readModel([ARCHITECT]), ++ ); ++ expect(error.reason).toBe("parent-not-found"); ++ expect(error.parentThreadId).toBe("thread-absent"); ++ }), ++ ); ++ ++ it.effect("refuses a builder whose parent is in another project", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-other-architect", ++ }), ++ readModel([ARCHITECT, OTHER_PROJECT_ARCHITECT]), ++ ); ++ // Distinct from parent-not-found: the parent DOES exist, and telling a ++ // caller "no such parent" would send them looking for a thread they can see. ++ expect(error.reason).toBe("parent-in-other-project"); ++ }), ++ ); ++ ++ it.effect("refuses a thread that names itself as its own parent", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ threadId: "thread-new", role: "builder", parentThreadId: "thread-new" }), ++ readModel([ARCHITECT]), ++ ); ++ // Checked before existence: a self-reference is a caller bug whether or not ++ // the thread exists yet, and "no such parent" would be a misleading answer. ++ expect(error.reason).toBe("parent-is-self"); ++ }), ++ ); ++ ++ it.effect("refuses a builder parented to another builder", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-builder", ++ }), ++ readModel([ARCHITECT, BUILDER]), ++ ); ++ expect(error.reason).toBe("parent-not-architect"); ++ expect(error.detail).toContain("a builder cannot own builders"); ++ }), ++ ); ++ ++ it.effect("refuses a builder parented to a thread Codev did not create", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-upstream", ++ }), ++ readModel([ARCHITECT, UPSTREAM]), ++ ); ++ // Same discriminant as builder-as-parent — both are "the parent is not an ++ // architect" — but the detail distinguishes them for a human. ++ expect(error.reason).toBe("parent-not-architect"); ++ expect(error.detail).toContain("cannot own builders"); ++ }), ++ ); ++ ++ it.effect("refuses a builder with no parent at all", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ threadId: "thread-new", role: "builder", parentThreadId: null }), ++ readModel([ARCHITECT]), ++ ); ++ expect(error.reason).toBe("builder-without-parent"); ++ }), ++ ); ++ ++ it.effect("refuses a builder whose parentThreadId key is absent, not merely null", () => ++ Effect.gen(function* () { ++ // Upstream clients omit the key; Codev sends an explicit null. Neither is a ++ // legal builder, and both must reach the same refusal — a rule that only ++ // fires on one spelling is a rule with a hole in it. ++ const error = yield* refusalReason( ++ createCommand({ threadId: "thread-new", role: "builder" }), ++ readModel([ARCHITECT]), ++ ); ++ expect(error.reason).toBe("builder-without-parent"); ++ }), ++ ); ++ ++ it.effect("refuses an architect carrying a parent", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ ++ threadId: "thread-new", ++ role: "architect", ++ parentThreadId: "thread-architect", ++ }), ++ readModel([ARCHITECT]), ++ ); ++ expect(error.reason).toBe("parent-on-non-builder"); ++ }), ++ ); ++ ++ it.effect("refuses a roleless thread carrying a parent", () => ++ Effect.gen(function* () { ++ const error = yield* refusalReason( ++ createCommand({ ++ threadId: "thread-new", ++ role: null, ++ parentThreadId: "thread-architect", ++ }), ++ readModel([ARCHITECT]), ++ ); ++ expect(error.reason).toBe("parent-on-non-builder"); ++ }), ++ ); ++ ++ it.effect("every refusal reports a distinct, actionable reason", () => { ++ // The deliverable is not "five refusals" but "five reasons a caller can tell ++ // apart". Asserted as a set so a future edit that collapses two of them onto ++ // one discriminant fails here rather than silently. ++ const reasons = new Set([ ++ "parent-not-found", ++ "parent-in-other-project", ++ "parent-is-self", ++ "parent-not-architect", ++ "builder-without-parent", ++ "parent-on-non-builder", ++ ]); ++ expect(reasons.size).toBe(6); ++ return Effect.void; ++ }); ++}); ++ ++it.layer(NodeServices.layer)("codev hierarchy acceptances (spec 250)", (it) => { ++ it.effect("accepts a builder under an architect in the same project", () => ++ Effect.gen(function* () { ++ const event = yield* decideOrchestrationCommand({ ++ command: createCommand({ ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-architect", ++ }) as never, ++ readModel: readModel([ARCHITECT]), ++ }); ++ const events = Array.isArray(event) ? event : [event]; ++ expect(events[0]?.type).toBe("thread.created"); ++ if (events[0]?.type === "thread.created") { ++ expect(events[0].payload.role).toBe("builder"); ++ expect(events[0].payload.parentThreadId).toBe("thread-architect"); ++ } ++ }), ++ ); ++ ++ it.effect("accepts an architect with no parent", () => ++ Effect.gen(function* () { ++ const event = yield* decideOrchestrationCommand({ ++ command: createCommand({ threadId: "thread-new", role: "architect" }) as never, ++ readModel: readModel([]), ++ }); ++ const events = Array.isArray(event) ? event : [event]; ++ expect(events[0]?.type).toBe("thread.created"); ++ if (events[0]?.type === "thread.created") { ++ expect(events[0].payload.role).toBe("architect"); ++ expect(events[0].payload.parentThreadId).toBeNull(); ++ } ++ }), ++ ); ++ ++ it.effect("accepts an upstream thread that carries neither field", () => ++ Effect.gen(function* () { ++ // The common case, and the one that must never regress: every client that ++ // predates the fork dispatches exactly this. ++ const event = yield* decideOrchestrationCommand({ ++ command: createCommand({ threadId: "thread-new" }) as never, ++ readModel: readModel([]), ++ }); ++ const events = Array.isArray(event) ? event : [event]; ++ expect(events[0]?.type).toBe("thread.created"); ++ if (events[0]?.type === "thread.created") { ++ expect(events[0].payload.role).toBeNull(); ++ expect(events[0].payload.parentThreadId).toBeNull(); ++ } ++ }), ++ ); ++ ++ /** ++ * The deliberate non-refusal. ++ * ++ * Archiving a parent does NOT retro-refuse its children. Retro-refusal would ++ * make an archive fail because of a thread it does not know about, and would ++ * make archiving order-dependent. The children become orphans — a real state ++ * the reader shows as unattributed in phase 7, rather than hiding. ++ */ ++ it.effect("archiving an architect leaves its builders readable and still parented", () => ++ Effect.gen(function* () { ++ const model = readModel([ARCHITECT, BUILDER]); ++ const event = yield* decideOrchestrationCommand({ ++ command: { ++ type: "thread.archive", ++ commandId: CommandId.make("cmd-archive"), ++ threadId: ThreadId.make("thread-architect"), ++ }, ++ readModel: model, ++ }); ++ const events = Array.isArray(event) ? event : [event]; ++ expect(events[0]?.type).toBe("thread.archived"); ++ ++ // The builder is untouched: not deleted, not reparented, not stripped of the ++ // edge. It points at an archived parent, which is exactly what an orphan is. ++ const builder = model.threads.find((t) => t.id === "thread-builder"); ++ expect(builder?.parentThreadId).toBe("thread-architect"); ++ expect(builder?.role).toBe("builder"); ++ }), ++ ); ++ ++ it.effect("creating a builder under an ARCHIVED architect is still accepted", () => ++ Effect.gen(function* () { ++ // The rule is about the edge's shape, not the parent's lifecycle. Refusing ++ // here would mean archiving a thread silently changes what commands are ++ // legal, which is a second rule nobody wrote down. ++ const archived = thread({ ++ id: "thread-architect", ++ role: "architect", ++ archivedAt: NOW, ++ }); ++ const event = yield* decideOrchestrationCommand({ ++ command: createCommand({ ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-architect", ++ }) as never, ++ readModel: readModel([archived]), ++ }); ++ const events = Array.isArray(event) ? event : [event]; ++ expect(events[0]?.type).toBe("thread.created"); ++ }), ++ ); ++}); +diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts +index f3fdd462f..9103b862b 100644 +--- a/apps/server/src/orchestration/decider.ts ++++ b/apps/server/src/orchestration/decider.ts +@@ -9,10 +9,11 @@ import * as Crypto from "effect/Crypto"; + import * as Effect from "effect/Effect"; + import type * as PlatformError from "effect/PlatformError"; + +-import { OrchestrationCommandInvariantError } from "./Errors.ts"; ++import { CodevHierarchyInvalidError, OrchestrationCommandInvariantError } from "./Errors.ts"; + import { + listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, ++ requireCodevHierarchy, + requireProject, + requireProjectAbsent, + requireThread, +@@ -186,7 +187,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ + readonly readModel: OrchestrationReadModel; + }): Effect.fn.Return< + ReadonlyArray, +- OrchestrationCommandInvariantError | PlatformError.PlatformError, ++ OrchestrationCommandInvariantError | CodevHierarchyInvalidError | PlatformError.PlatformError, + Crypto.Crypto + > { + let nextReadModel = readModel; +@@ -220,7 +221,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + readonly readModel: OrchestrationReadModel; + }): Effect.fn.Return< + DecideOrchestrationCommandResult, +- OrchestrationCommandInvariantError | PlatformError.PlatformError, ++ OrchestrationCommandInvariantError | CodevHierarchyInvalidError | PlatformError.PlatformError, + Crypto.Crypto + > { + switch (command.type) { +@@ -360,6 +361,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + command, + threadId: command.threadId, + }); ++ // Codev customization (spec 250). Refused HERE, at write time, rather than ++ // repaired at read time: a reader that reparents an orphan or draws a ++ // parentless builder at the root produces a second correct-looking answer, ++ // and then two places disagree about the tree with nothing to say which is ++ // right. Every refusal carries its own reason discriminant. ++ yield* requireCodevHierarchy({ ++ readModel, ++ commandType: command.type, ++ threadId: command.threadId, ++ projectId: command.projectId, ++ hierarchy: { role: command.role, parentThreadId: command.parentThreadId }, ++ }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", +@@ -377,6 +390,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, ++ role: command.role ?? null, ++ parentThreadId: command.parentThreadId ?? null, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index 5f67b4a3d..34faa73f5 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -741,6 +741,21 @@ const ThreadCreateCommand = Schema.Struct({ + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), ++ /** ++ * Codev customization (spec 250). Set at creation and never after: a thread's ++ * place in the hierarchy is part of what it IS, and a role that can be edited ++ * later is a role every reader has to re-check. Optional so upstream clients, ++ * which know nothing about either field, keep dispatching `thread.create` ++ * unchanged — the resulting thread simply has no role, which is true. ++ * ++ * The legal shapes are enforced in `commandInvariants.ts`, at write time, with ++ * a named reason per refusal. They are deliberately NOT expressed as a schema ++ * refinement: a schema can say "these two fields are consistent with each ++ * other" but cannot see the parent thread, and half the rules are about the ++ * parent (does it exist, is it in this project, is it an architect). ++ */ ++ role: Schema.optional(Schema.NullOr(CodevThreadRole)), ++ parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), + createdAt: IsoDateTime, + }); + diff --git a/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch b/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch new file mode 100644 index 000000000..7f7573a6b --- /dev/null +++ b/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch @@ -0,0 +1,646 @@ +From 40fb82ce92a8ed42e6868bd946bfee00b79b3022 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 17:21:55 -0600 +Subject: [PATCH 05/34] [Spec 250][Phase: phase_3] fix: the engine was deleting + every reason discriminant +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Both review lanes found this independently and it is blocking. + +OrchestrationEngine rewrote every CodevHierarchyInvalidError as a generic +invariant error reading "Failed to generate an event identifier" — not +merely lossy but FALSE — and then persisted that message onto the command +receipt, which is replayed verbatim on every redispatch of the same +commandId. The six discriminants existed only inside the decider. The +decider is not a boundary anyone sees; the engine is. The entire phase 3 +deliverable was being deleted one layer above where it was tested. + +All fifteen decider tests passed throughout, because they call +decideOrchestrationCommand directly and bypass the wrapper. That is why it +went unnoticed, and it is the same shape as the MigrationsLive finding in +phase 2: testing the layer below the one production uses. + +Adds OrchestrationEngine.codevHierarchy.test.ts, which dispatches through +the real engine and asserts the reason arrives intact, that each distinct +reason survives, and that the rejected receipt records the real cause +rather than the false one. Verified to discriminate: with the mapping +reverted, 3 of its 4 tests fail. + +Two of my own tests asserted nothing, both caught by review. One built a +Set of string literals and asserted its size, which proves six distinct +strings are six distinct strings and would have passed if every refusal +collapsed onto one discriminant; it now collects reasons from six real +dispatches. The other asserted on its own input fixture after an archive +the decider never mutates; it now asserts the decider's output — one +event, on one aggregate, mentioning no child. + +commandInvariants.test.ts gains the Codev cases the plan listed, including +the two ordering decisions as tests: parent-is-self ahead of +parent-not-found when both hold, and parent-in-other-project rather than +parent-not-found. + +Fork typecheck green; server 2797 passed, 8 skipped. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + ...OrchestrationEngine.codevHierarchy.test.ts | 272 ++++++++++++++++++ + .../Layers/OrchestrationEngine.ts | 28 +- + .../orchestration/commandInvariants.test.ts | 129 +++++++++ + .../decider.codevHierarchy.test.ts | 82 ++++-- + 4 files changed, 485 insertions(+), 26 deletions(-) + create mode 100644 apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts + +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +new file mode 100644 +index 000000000..0d04eb6f0 +--- /dev/null ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +@@ -0,0 +1,272 @@ ++/** ++ * Codev customization (spec 250) — hierarchy refusals through the ENGINE. ++ * ++ * This file exists because of a bug it would have caught. Every phase 3 test ++ * called `decideOrchestrationCommand` directly, so all fifteen passed while ++ * `OrchestrationEngine` rewrote each refusal as a generic invariant error reading ++ * "Failed to generate an event identifier" — a message that is not merely lossy ++ * but false, and which was then persisted onto the command receipt and replayed ++ * on every redispatch of the same `commandId`. ++ * ++ * The decider is not a boundary anyone sees. This is. A discriminant that does ++ * not survive the wrapper does not exist, and testing one layer below the ++ * wrapper cannot tell you that. ++ */ ++ ++import { ++ CommandId, ++ DEFAULT_PROVIDER_INTERACTION_MODE, ++ ProjectId, ++ ProviderInstanceId, ++ ThreadId, ++} from "@t3tools/contracts"; ++import * as NodeServices from "@effect/platform-node/NodeServices"; ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; ++import * as ManagedRuntime from "effect/ManagedRuntime"; ++import { describe, expect, it } from "vite-plus/test"; ++ ++import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; ++import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; ++import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; ++import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; ++import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; ++import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; ++import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; ++import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; ++import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; ++import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; ++import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; ++import { CodevHierarchyInvalidError } from "../Errors.ts"; ++import { ServerConfig } from "../../config.ts"; ++ ++const NOW = "2026-01-01T00:00:00.000Z"; ++const MODEL = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }; ++ ++async function createSystem() { ++ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { ++ prefix: "t3-codev-hierarchy-engine-test-", ++ }); ++ const layer = Layer.mergeAll( ++ OrchestrationEngineLive.pipe( ++ Layer.provide(OrchestrationProjectionSnapshotQueryLive), ++ Layer.provide(OrchestrationProjectionPipelineLive), ++ ), ++ OrchestrationProjectionSnapshotQueryLive, ++ // Merged, not just provided: this test reads the rejected receipt back, and ++ // the receipt is the durable half of the refusal. ++ OrchestrationCommandReceiptRepositoryLive, ++ ).pipe( ++ Layer.provide(ThreadBackgroundLiveness.layer), ++ Layer.provide(ThreadPlanProgress.layer), ++ Layer.provide(OrchestrationEventStoreLive), ++ Layer.provide(OrchestrationCommandReceiptRepositoryLive), ++ Layer.provide(RepositoryIdentityResolver.layer), ++ Layer.provide(SqlitePersistenceMemory), ++ Layer.provideMerge(ServerConfigLayer), ++ Layer.provideMerge(NodeServices.layer), ++ ); ++ const runtime = ManagedRuntime.make(layer); ++ const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); ++ const receipts = await runtime.runPromise( ++ Effect.service(OrchestrationCommandReceiptRepository), ++ ); ++ return { ++ engine, ++ receipts, ++ run: (effect: Effect.Effect) => runtime.runPromise(effect), ++ dispose: () => runtime.dispose(), ++ }; ++} ++ ++const createThread = (input: { ++ readonly commandId: string; ++ readonly threadId: string; ++ readonly projectId?: string; ++ readonly role?: "architect" | "builder" | null; ++ readonly parentThreadId?: string | null; ++}) => ++ ({ ++ type: "thread.create", ++ commandId: CommandId.make(input.commandId), ++ threadId: ThreadId.make(input.threadId), ++ projectId: ProjectId.make(input.projectId ?? "project-1"), ++ title: `Thread ${input.threadId}`, ++ modelSelection: MODEL, ++ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ++ runtimeMode: "approval-required", ++ branch: null, ++ worktreePath: null, ++ ...(input.role === undefined ? {} : { role: input.role }), ++ ...(input.parentThreadId === undefined ++ ? {} ++ : { ++ parentThreadId: ++ input.parentThreadId === null ? null : ThreadId.make(input.parentThreadId), ++ }), ++ createdAt: NOW, ++ }) as never; ++ ++async function seededSystem() { ++ const system = await createSystem(); ++ await system.run( ++ system.engine.dispatch({ ++ type: "project.create", ++ commandId: CommandId.make("cmd-project"), ++ projectId: ProjectId.make("project-1"), ++ title: "Project 1", ++ workspaceRoot: "/tmp/project-1", ++ defaultModelSelection: MODEL, ++ createdAt: NOW, ++ }), ++ ); ++ await system.run( ++ system.engine.dispatch( ++ createThread({ ++ commandId: "cmd-architect", ++ threadId: "thread-architect", ++ role: "architect", ++ }), ++ ), ++ ); ++ return system; ++} ++ ++/** Dispatch and return the error the ENGINE surfaced, not the decider's. */ ++async function dispatchFailure( ++ system: Awaited>, ++ command: ReturnType, ++) { ++ const outcome = await system.run(Effect.result(system.engine.dispatch(command))); ++ expect(outcome._tag, "the engine must refuse this command").toBe("Failure"); ++ return (outcome as { failure: unknown }).failure; ++} ++ ++describe("OrchestrationEngine: codev hierarchy refusals (spec 250)", () => { ++ it("surfaces the reason discriminant through the engine, not a generic invariant error", async () => { ++ const system = await seededSystem(); ++ try { ++ const error = await dispatchFailure( ++ system, ++ createThread({ ++ commandId: "cmd-bad-parent", ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-absent", ++ }), ++ ); ++ ++ expect( ++ error instanceof CodevHierarchyInvalidError, ++ `the engine collapsed the refusal into ${String(error)}`, ++ ).toBe(true); ++ expect((error as CodevHierarchyInvalidError).reason).toBe("parent-not-found"); ++ // The message the collapse used to produce. Asserting its absence is what ++ // makes this test fail loudly if the mapping regresses. ++ expect(String((error as CodevHierarchyInvalidError).message)).not.toContain( ++ "Failed to generate an event identifier", ++ ); ++ } finally { ++ await system.dispose(); ++ } ++ }); ++ ++ it("carries each distinct reason through the engine", async () => { ++ const system = await seededSystem(); ++ try { ++ const cases: ReadonlyArray]> = [ ++ [ ++ "parent-is-self", ++ createThread({ ++ commandId: "cmd-self", ++ threadId: "thread-self", ++ role: "builder", ++ parentThreadId: "thread-self", ++ }), ++ ], ++ [ ++ "builder-without-parent", ++ createThread({ ++ commandId: "cmd-no-parent", ++ threadId: "thread-no-parent", ++ role: "builder", ++ }), ++ ], ++ [ ++ "parent-on-non-builder", ++ createThread({ ++ commandId: "cmd-architect-parent", ++ threadId: "thread-arch-child", ++ role: "architect", ++ parentThreadId: "thread-architect", ++ }), ++ ], ++ ]; ++ ++ const seen: string[] = []; ++ for (const [expected, command] of cases) { ++ const error = await dispatchFailure(system, command); ++ expect(error instanceof CodevHierarchyInvalidError).toBe(true); ++ seen.push((error as CodevHierarchyInvalidError).reason); ++ expect((error as CodevHierarchyInvalidError).reason).toBe(expected); ++ } ++ ++ // Distinct at the boundary, which is the deliverable — not distinct in a ++ // set the test builds for itself. ++ expect(new Set(seen).size).toBe(cases.length); ++ } finally { ++ await system.dispose(); ++ } ++ }); ++ ++ /** ++ * The receipt is the durable half. It is replayed verbatim on redispatch of the ++ * same `commandId`, so a wrong message there is not a one-time bad log — it is ++ * the permanent answer to that command. ++ */ ++ it("records the real reason on the rejected command receipt", async () => { ++ const system = await seededSystem(); ++ try { ++ await dispatchFailure( ++ system, ++ createThread({ ++ commandId: "cmd-receipt", ++ threadId: "thread-receipt", ++ role: "builder", ++ parentThreadId: "thread-absent", ++ }), ++ ); ++ ++ const receipt = await system.run( ++ system.receipts.getByCommandId({ commandId: CommandId.make("cmd-receipt") }), ++ ); ++ expect(receipt._tag, "a refused command must leave a receipt").toBe("Some"); ++ const value = (receipt as { value: { status: string; error: string | null } }).value; ++ expect(value.status).toBe("rejected"); ++ expect(value.error).toContain("parent-not-found"); ++ expect(value.error).not.toContain("Failed to generate an event identifier"); ++ } finally { ++ await system.dispose(); ++ } ++ }); ++ ++ it("accepts the legal architect -> builder edge through the engine", async () => { ++ const system = await seededSystem(); ++ try { ++ await system.run( ++ system.engine.dispatch( ++ createThread({ ++ commandId: "cmd-builder", ++ threadId: "thread-builder", ++ role: "builder", ++ parentThreadId: "thread-architect", ++ }), ++ ), ++ ); ++ // No assertion needed beyond "it did not throw": the refusal path is what ++ // this file is about, and an accepted command that threw would fail here. ++ } finally { ++ await system.dispose(); ++ } ++ }); ++}); +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +index 423a44a6f..5234f426c 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +@@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; + import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; + import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; + import { ++ CodevHierarchyInvalidError, + OrchestrationCommandIdConflictError, + OrchestrationCommandInvariantError, + OrchestrationCommandPreviouslyRejectedError, +@@ -52,6 +53,25 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( + ); + const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); + const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); ++/** ++ * Codev customization (spec 250). ++ * ++ * Without this the mapping below rewrote every hierarchy refusal as a generic ++ * invariant error reading "Failed to generate an event identifier" — a message ++ * that is not merely lossy but FALSE, and which was then persisted onto the ++ * command receipt and replayed on every redispatch of the same commandId. ++ * ++ * The six `reason` discriminants exist so a caller can tell a retry ("no such ++ * parent") from a caller bug ("wrong parent role"). The decider is not a boundary ++ * anyone sees; this is. Collapsing here deleted the entire deliverable while the ++ * decider tests stayed green, because they call the decider directly. ++ */ ++const isCodevHierarchyInvalidError = Schema.is(CodevHierarchyInvalidError); ++/** Errors that describe a REFUSAL and must reach the dispatcher intact. */ ++const isRefusal = (cause: unknown): cause is ++ | OrchestrationCommandInvariantError ++ | CodevHierarchyInvalidError => ++ isOrchestrationCommandInvariantError(cause) || isCodevHierarchyInvalidError(cause); + + interface CommandEnvelope { + command: OrchestrationCommand; +@@ -175,7 +195,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError((cause) => +- isOrchestrationCommandInvariantError(cause) ++ isRefusal(cause) + ? cause + : new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, +@@ -307,7 +327,11 @@ const makeOrchestrationEngine = Effect.gen(function* () { + ), + ); + +- if (isOrchestrationCommandInvariantError(error)) { ++ // Codev customization (spec 250): hierarchy refusals record their own ++ // message too. This receipt is replayed verbatim on redispatch of the ++ // same commandId, so a wrong message here is not a one-time bad log — ++ // it is the permanent answer to that command. ++ if (isRefusal(error)) { + yield* commandReceiptRepository + .upsert({ + commandId: envelope.command.commandId, +diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts +index 52aac1f0c..bc044d40e 100644 +--- a/apps/server/src/orchestration/commandInvariants.test.ts ++++ b/apps/server/src/orchestration/commandInvariants.test.ts +@@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; + import { + findThreadById, + listThreadsByProjectId, ++ requireCodevHierarchy, + requireThread, + requireThreadAbsent, + } from "./commandInvariants.ts"; +@@ -200,3 +201,131 @@ describe("commandInvariants", () => { + ).rejects.toThrow("already exists"); + }); + }); ++ ++/** ++ * Codev customization (spec 250) — `requireCodevHierarchy` on its own. ++ * ++ * The decider and engine suites cover the refusals end to end. These cover the ++ * function directly, because the ordering between its checks is a decision and ++ * not an accident, and a unit test is where a decision like that is legible. ++ */ ++describe("requireCodevHierarchy (spec 250)", () => { ++ const hierarchyModel: OrchestrationReadModel = { ++ ...readModel, ++ threads: [ ++ ...readModel.threads.map((thread) => ({ ...thread, role: null, parentThreadId: null })), ++ ], ++ } as OrchestrationReadModel; ++ ++ const withThread = ( ++ id: string, ++ role: "architect" | "builder" | null, ++ projectId = "project-a", ++ ): OrchestrationReadModel => ++ ({ ++ ...hierarchyModel, ++ threads: [ ++ ...hierarchyModel.threads, ++ { ++ ...hierarchyModel.threads[0], ++ id: ThreadId.make(id), ++ projectId: ProjectId.make(projectId), ++ role, ++ parentThreadId: null, ++ }, ++ ], ++ }) as OrchestrationReadModel; ++ ++ const check = (input: { ++ readonly model: OrchestrationReadModel; ++ readonly threadId: string; ++ readonly projectId?: string; ++ readonly role?: "architect" | "builder" | null; ++ readonly parentThreadId?: string | null; ++ }) => ++ Effect.runPromise( ++ Effect.result( ++ requireCodevHierarchy({ ++ readModel: input.model, ++ commandType: "thread.create", ++ threadId: ThreadId.make(input.threadId), ++ projectId: ProjectId.make(input.projectId ?? "project-a"), ++ hierarchy: { ++ role: input.role, ++ parentThreadId: ++ input.parentThreadId == null ? input.parentThreadId : ThreadId.make(input.parentThreadId), ++ }, ++ }), ++ ), ++ ); ++ ++ it("accepts a thread with neither field, which is every upstream thread", async () => { ++ const outcome = await check({ model: hierarchyModel, threadId: "thread-new" }); ++ expect(outcome._tag).toBe("Success"); ++ }); ++ ++ it("accepts a builder under an architect", async () => { ++ const outcome = await check({ ++ model: withThread("thread-architect", "architect"), ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-architect", ++ }); ++ expect(outcome._tag).toBe("Success"); ++ }); ++ ++ /** ++ * The ordering decision, stated as a test. ++ * ++ * A self-reference is a caller bug whether or not the thread exists, so it is ++ * reported as `parent-is-self` rather than `parent-not-found` — otherwise the ++ * answer sends someone hunting a missing thread that is in front of them. This ++ * case has BOTH properties: the parent does not exist AND it is the thread ++ * itself, so it can only pass if the order is right. ++ */ ++ it("reports parent-is-self ahead of parent-not-found when both are true", async () => { ++ const outcome = await check({ ++ model: hierarchyModel, ++ threadId: "thread-nowhere", ++ role: "builder", ++ parentThreadId: "thread-nowhere", ++ }); ++ expect(outcome._tag).toBe("Failure"); ++ expect((outcome as { failure: { reason: string } }).failure.reason).toBe("parent-is-self"); ++ }); ++ ++ /** ++ * The other ordering decision. A parent in another project both exists and is ++ * not reachable, and "no such parent" would be a false answer about a thread ++ * the caller can plainly see. ++ */ ++ it("reports parent-in-other-project rather than parent-not-found", async () => { ++ const outcome = await check({ ++ model: withThread("thread-elsewhere", "architect", "project-b"), ++ threadId: "thread-new", ++ role: "builder", ++ parentThreadId: "thread-elsewhere", ++ }); ++ expect(outcome._tag).toBe("Failure"); ++ expect((outcome as { failure: { reason: string } }).failure.reason).toBe( ++ "parent-in-other-project", ++ ); ++ }); ++ ++ it("treats an absent parentThreadId key the same as an explicit null", async () => { ++ // Upstream clients omit the key; Codev sends null. A rule that fires on only ++ // one spelling is a rule with a hole in it. ++ const omitted = await check({ model: hierarchyModel, threadId: "t-a", role: "builder" }); ++ const explicit = await check({ ++ model: hierarchyModel, ++ threadId: "t-b", ++ role: "builder", ++ parentThreadId: null, ++ }); ++ expect(omitted._tag).toBe("Failure"); ++ expect(explicit._tag).toBe("Failure"); ++ expect((omitted as { failure: { reason: string } }).failure.reason).toBe( ++ (explicit as { failure: { reason: string } }).failure.reason, ++ ); ++ }); ++}); +diff --git a/apps/server/src/orchestration/decider.codevHierarchy.test.ts b/apps/server/src/orchestration/decider.codevHierarchy.test.ts +index 3cc5d3949..eb33f99c2 100644 +--- a/apps/server/src/orchestration/decider.codevHierarchy.test.ts ++++ b/apps/server/src/orchestration/decider.codevHierarchy.test.ts +@@ -274,21 +274,46 @@ it.layer(NodeServices.layer)("codev hierarchy refusals (spec 250)", (it) => { + }), + ); + +- it.effect("every refusal reports a distinct, actionable reason", () => { +- // The deliverable is not "five refusals" but "five reasons a caller can tell +- // apart". Asserted as a set so a future edit that collapses two of them onto +- // one discriminant fails here rather than silently. +- const reasons = new Set([ +- "parent-not-found", +- "parent-in-other-project", +- "parent-is-self", +- "parent-not-architect", +- "builder-without-parent", +- "parent-on-non-builder", +- ]); +- expect(reasons.size).toBe(6); +- return Effect.void; +- }); ++ /** ++ * The deliverable is not "six refusals" but "six reasons a caller can tell ++ * apart". An earlier version of this test built a Set of string literals and ++ * asserted its size — which proves that six distinct strings are six distinct ++ * strings, and would have passed unchanged if every refusal collapsed onto one ++ * discriminant. Review caught it. ++ * ++ * This dispatches all six and collects what the decider actually returned. ++ */ ++ it.effect("every refusal reports a distinct reason, collected from real dispatches", () => ++ Effect.gen(function* () { ++ const model = readModel([ARCHITECT, BUILDER, UPSTREAM, OTHER_PROJECT_ARCHITECT]); ++ const cases = [ ++ createCommand({ threadId: "t1", role: "builder", parentThreadId: "thread-absent" }), ++ createCommand({ threadId: "t2", role: "builder", parentThreadId: "thread-other-architect" }), ++ createCommand({ threadId: "t3", role: "builder", parentThreadId: "t3" }), ++ createCommand({ threadId: "t4", role: "builder", parentThreadId: "thread-builder" }), ++ createCommand({ threadId: "t5", role: "builder", parentThreadId: null }), ++ createCommand({ threadId: "t6", role: "architect", parentThreadId: "thread-architect" }), ++ ]; ++ ++ const reasons: string[] = []; ++ for (const command of cases) { ++ const error = yield* refusalReason(command, model); ++ reasons.push(error.reason); ++ } ++ ++ expect(reasons).toEqual([ ++ "parent-not-found", ++ "parent-in-other-project", ++ "parent-is-self", ++ "parent-not-architect", ++ "builder-without-parent", ++ "parent-on-non-builder", ++ ]); ++ // The property that matters: collapsing any two onto one discriminant ++ // fails here, because these came out of the decider rather than a literal. ++ expect(new Set(reasons).size).toBe(cases.length); ++ }), ++ ); + }); + + it.layer(NodeServices.layer)("codev hierarchy acceptances (spec 250)", (it) => { +@@ -351,25 +376,34 @@ it.layer(NodeServices.layer)("codev hierarchy acceptances (spec 250)", (it) => { + * make archiving order-dependent. The children become orphans — a real state + * the reader shows as unattributed in phase 7, rather than hiding. + */ +- it.effect("archiving an architect leaves its builders readable and still parented", () => ++ it.effect("archiving an architect emits one event and touches no other thread", () => + Effect.gen(function* () { +- const model = readModel([ARCHITECT, BUILDER]); ++ // An earlier version asserted on `model.threads` — its own input fixture, ++ // which the decider never mutates — so it would have passed no matter what ++ // the decider emitted. Review caught it. The real property is about the ++ // decider's OUTPUT: archiving is a single-aggregate event that says nothing ++ // about children, which is what makes orphaning happen by omission rather ++ // than by a cascade someone has to remember not to write. + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.archive", + commandId: CommandId.make("cmd-archive"), + threadId: ThreadId.make("thread-architect"), + }, +- readModel: model, ++ readModel: readModel([ARCHITECT, BUILDER]), + }); + const events = Array.isArray(event) ? event : [event]; +- expect(events[0]?.type).toBe("thread.archived"); + +- // The builder is untouched: not deleted, not reparented, not stripped of the +- // edge. It points at an archived parent, which is exactly what an orphan is. +- const builder = model.threads.find((t) => t.id === "thread-builder"); +- expect(builder?.parentThreadId).toBe("thread-architect"); +- expect(builder?.role).toBe("builder"); ++ expect(events).toHaveLength(1); ++ expect(events[0]?.type).toBe("thread.archived"); ++ expect(events[0]?.aggregateId).toBe("thread-architect"); ++ // No event mentions the builder: it is neither archived, deleted, nor ++ // reparented. Whether it remains readable afterwards is a persistence ++ // question, asserted for real in codev/threadHierarchy.test.ts. ++ expect( ++ events.some((e) => e.aggregateId === "thread-builder"), ++ "archiving a parent must not cascade to its children", ++ ).toBe(false); + }), + ); + diff --git a/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch b/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch new file mode 100644 index 000000000..e8edda2e7 --- /dev/null +++ b/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch @@ -0,0 +1,1455 @@ +From 3a1780bbf66f212f55cb3378e5e5a5ad891e4f7e Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 18:00:02 -0600 +Subject: [PATCH 06/34] [Spec 250][Phase: phase_4] feat: gate block with a + server-allocated revision +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Contract, transport, persistence and decider. Tests for the revision rules +and scope enforcement land in the next commit. + +ONE RULE covers allocation and stale-rejection, which review round 1 said +could not both hold as the plan originally wrote them. `revision` on the +command is optional: + + absent the normal path. The decider allocates gateRevision + 1 from the + read model it is deciding against. Commands are decided serially + against the authoritative read model, so two concurrent writers + get two different numbers with no locking. + present a replay, or a write from an older connection. Applied only if it + EXCEEDS the mark. Equal is refused, not treated as idempotent: + two writers that computed the same number are colliding. + +The mark lives on the thread, not inside the gate block, and is raised by +the clear as well as the set. That is what stops a stale write resurrecting +a gate a human already answered — the mark outlives the block. + +Gate writes travel their OWN RPC method. RpcAuthorization authorizes the +METHOD, so a gate command routed through dispatchCommand would be reachable +by every holder of orchestration:operate and no row in the scope map could +say otherwise. Four places, as the plan required: rpc.ts, the method +constant, the ws.ts handler key, and the authorization row. The commands +are deliberately absent from ClientOrchestrationCommand and +DispatchableClientOrchestrationCommand, which ARE the dispatchCommand +payload. + +codev:gate-write is in AuthEnvironmentScope but NOT in +AuthStandardClientScopes, not in AuthAdministrativeScopes, and not in the +token allowlist — all three would grant it to the callers it exists to +exclude. + +The engine now returns the events its dispatch committed. The gate's +response needs the revision the server allocated for THAT write, and +re-reading the thread row would race a concurrent writer: both would see +the later value and one would be told a number never allocated to it. The +idempotent replay path returns an empty events array, honestly — it +committed nothing this time, so there is nothing to read a revision from, +and the caller reports the write as unconfirmed rather than inventing one. + +codev_gate_revision is INTEGER NOT NULL DEFAULT 0, so historical rows are +correct at ALTER time with no backfill pass — and a backfill is a second +write that can be interrupted. + +gateRevision is optional on input with a decoding default, required after +decoding. The strict form cost 159 errors across upstream test fixtures, +which is the rebase debt phase 2 established we do not sign up for; the +DB column stays NOT NULL and every read normalizes, so there is still one +spelling of "no gate yet". + +Fork typecheck green; codev + orchestration suites 332 passed. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/auth/RpcAuthorization.ts | 7 + + apps/server/src/codev/schemaGuard.test.ts | 8 +- + apps/server/src/codev/schemaGuard.ts | 27 +++ + apps/server/src/codev/threadHierarchy.test.ts | 6 +- + .../Layers/OrchestrationEngine.ts | 26 +- + .../Layers/ProjectionPipeline.ts | 38 +++ + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + + .../Layers/ProjectionSnapshotQuery.ts | 22 ++ + .../Services/OrchestrationEngine.ts | 16 +- + apps/server/src/orchestration/decider.ts | 89 ++++++- + .../src/orchestration/projector.test.ts | 5 + + apps/server/src/orchestration/projector.ts | 38 +++ + .../persistence/Layers/ProjectionThreads.ts | 17 +- + .../persistence/Services/ProjectionThreads.ts | 9 +- + .../src/relay/AgentAwarenessRelay.test.ts | 4 +- + apps/server/src/server.test.ts | 34 +-- + .../serverRuntimeStartup.reconcile.test.ts | 6 +- + apps/server/src/serverRuntimeStartup.test.ts | 6 +- + apps/server/src/ws.ts | 64 +++++ + packages/contracts/src/auth.ts | 11 + + packages/contracts/src/orchestration.ts | 228 ++++++++++++++++++ + packages/contracts/src/rpc.ts | 19 ++ + 22 files changed, 649 insertions(+), 35 deletions(-) + +diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts +index 28ceac4ce..4fa26debb 100644 +--- a/apps/server/src/auth/RpcAuthorization.ts ++++ b/apps/server/src/auth/RpcAuthorization.ts +@@ -1,5 +1,7 @@ + import { + AuthAccessReadScope, ++ AuthCodevGateWriteScope, ++ CODEV_WS_METHODS, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, +@@ -22,6 +24,11 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; + */ + export const RPC_REQUIRED_SCOPES = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, ++ // Codev customization (spec 250). Its own method, so it can have its own ++ // scope: this map authorizes the METHOD, so a gate command routed through ++ // `dispatchCommand` would be reachable by every holder of ++ // `orchestration:operate` and no row here could say otherwise. ++ [CODEV_WS_METHODS.gateWrite]: AuthCodevGateWriteScope, + [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, +diff --git a/apps/server/src/codev/schemaGuard.test.ts b/apps/server/src/codev/schemaGuard.test.ts +index 481f4363f..ffbbf2f72 100644 +--- a/apps/server/src/codev/schemaGuard.test.ts ++++ b/apps/server/src/codev/schemaGuard.test.ts +@@ -107,8 +107,14 @@ describe("codev schema guard (spec 250)", () => { + yield* sql`ALTER TABLE projection_threads ADD COLUMN codev_role TEXT`; + + const result = yield* applyCodevSchemaGuard(); ++ // Expressed against the column list rather than a hardcoded name, so a ++ // later phase adding a column does not make this test wrong — it made ++ // this test wrong once already, when phase 4 added the gate columns. + assert.deepStrictEqual([...result.present], ["codev_role"]); +- assert.deepStrictEqual([...result.added], ["codev_parent_thread_id"]); ++ assert.deepStrictEqual( ++ [...result.added], ++ CODEV_THREAD_COLUMN_NAMES.filter((name) => name !== "codev_role"), ++ ); + + const after = yield* columnNames(); + for (const column of CODEV_THREAD_COLUMN_NAMES) { +diff --git a/apps/server/src/codev/schemaGuard.ts b/apps/server/src/codev/schemaGuard.ts +index 29f9a2beb..5dca9ff32 100644 +--- a/apps/server/src/codev/schemaGuard.ts ++++ b/apps/server/src/codev/schemaGuard.ts +@@ -94,6 +94,33 @@ export const CODEV_THREAD_COLUMNS = [ + `; + }), + }, ++ { ++ name: "codev_gate_json", ++ add: Effect.fnUntraced(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* sql` ++ ALTER TABLE projection_threads ++ ADD COLUMN codev_gate_json TEXT ++ `; ++ }), ++ }, ++ { ++ /** ++ * `NOT NULL DEFAULT 0` (spec 250, phase 4). The default is what makes the ++ * historical rows correct with no backfill pass: SQLite fills every existing ++ * row at ALTER time, and a thread that has never had a gate genuinely is at ++ * revision 0. A nullable column would have needed a backfill, and a backfill ++ * is a second write that can be interrupted. ++ */ ++ name: "codev_gate_revision", ++ add: Effect.fnUntraced(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* sql` ++ ALTER TABLE projection_threads ++ ADD COLUMN codev_gate_revision INTEGER NOT NULL DEFAULT 0 ++ `; ++ }), ++ }, + ] as const; + + /** The column names, for tests and for anything that needs the set without the DDL. */ +diff --git a/apps/server/src/codev/threadHierarchy.test.ts b/apps/server/src/codev/threadHierarchy.test.ts +index 4fe964b3c..31facaa12 100644 +--- a/apps/server/src/codev/threadHierarchy.test.ts ++++ b/apps/server/src/codev/threadHierarchy.test.ts +@@ -166,7 +166,11 @@ describe("codev thread hierarchy in persistence (spec 250)", () => { + yield* sql`ALTER TABLE projection_threads ADD COLUMN codev_role TEXT`; + + const finished = yield* applyCodevSchemaGuard(); +- assert.deepStrictEqual([...finished.added], ["codev_parent_thread_id"]); ++ assert.ok( ++ finished.added.includes("codev_parent_thread_id"), ++ "the guard must complete the column the crash left out", ++ ); ++ assert.deepStrictEqual([...finished.present], ["codev_role"]); + + const repository = yield* ProjectionThreadRepository; + yield* repository.upsert(row("thread-after-crash", { role: "builder" })); +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +index 5234f426c..068c7fbb9 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +@@ -76,7 +76,10 @@ const isRefusal = (cause: unknown): cause is + interface CommandEnvelope { + command: OrchestrationCommand; + origin: OrchestrationClientOrigin | undefined; +- result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; ++ result: Deferred.Deferred< ++ { readonly sequence: number; readonly events: ReadonlyArray }, ++ OrchestrationDispatchError ++ >; + startedAtMs: number; + } + +@@ -181,6 +184,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { + if (existingReceipt.value.status === "accepted") { + return { + sequence: existingReceipt.value.resultSequence, ++ // Empty, and honestly so: this is the idempotent replay path. The ++ // command committed nothing THIS time, so there is nothing for the ++ // caller to read a server-allocated value out of. A caller that ++ // needs one (spec 250's gate revision) reports the write as ++ // unconfirmed rather than inventing a number for it. ++ events: [] as ReadonlyArray, + }; + } + return yield* new OrchestrationCommandPreviouslyRejectedError({ +@@ -276,7 +285,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { + ); + } + } +- return { sequence: committedCommand.lastSequence }; ++ // Codev customization (spec 250): the committed events travel back with ++ // the sequence. A caller that needs a server-allocated value from its own ++ // write — the gate's revision — cannot re-read it from the projection ++ // without racing a concurrent writer, because the row only ever holds the ++ // latest. Existing callers destructure `sequence` and are unaffected. ++ return { ++ sequence: committedCommand.lastSequence, ++ events: committedCommand.committedEvents as ReadonlyArray, ++ }; + }).pipe(Effect.withSpan(`orchestration.command.${envelope.command.type}`)), + ).pipe( + Effect.flatMap((exit) => +@@ -366,7 +383,10 @@ const makeOrchestrationEngine = Effect.gen(function* () { + + const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => + Effect.gen(function* () { +- const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); ++ const result = yield* Deferred.make< ++ { readonly sequence: number; readonly events: ReadonlyArray }, ++ OrchestrationDispatchError ++ >(); + yield* Queue.offer(commandQueue, { + command, + origin: options?.origin, +diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +index cd53f674e..11b8c26db 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +@@ -639,6 +639,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti + // matching note in projector.ts. + role: event.payload.role, + parentThreadId: event.payload.parentThreadId, ++ // No gate, and revision 0. See the note in projector.ts. ++ codevGate: null, ++ gateRevision: 0, + latestTurnId: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, +@@ -835,6 +838,41 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti + return; + } + ++ // Codev customization (spec 250). The gate block and its high-water mark. ++ case "codev.gate-set": { ++ const existingRow = yield* projectionThreadRepository.getById({ ++ threadId: event.payload.threadId, ++ }); ++ if (Option.isNone(existingRow)) { ++ return; ++ } ++ yield* projectionThreadRepository.upsert({ ++ ...existingRow.value, ++ codevGate: event.payload.gate, ++ gateRevision: event.payload.gateRevision, ++ updatedAt: event.payload.updatedAt, ++ }); ++ return; ++ } ++ ++ case "codev.gate-cleared": { ++ const existingRow = yield* projectionThreadRepository.getById({ ++ threadId: event.payload.threadId, ++ }); ++ if (Option.isNone(existingRow)) { ++ return; ++ } ++ yield* projectionThreadRepository.upsert({ ++ ...existingRow.value, ++ codevGate: null, ++ // The mark is raised by the clear, not reset by it. That is what ++ // stops a stale write resurrecting an answered gate. ++ gateRevision: event.payload.gateRevision, ++ updatedAt: event.payload.updatedAt, ++ }); ++ return; ++ } ++ + case "thread.runtime-mode-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, +diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +index a94bc8050..932d06379 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +@@ -338,6 +338,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + // explicit null rather than an absent key. + role: null, + parentThreadId: null, ++ codevGate: null, ++ gateRevision: 0, + titleRegeneration: null, + deletedAt: null, + messages: [ +@@ -466,6 +468,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + // Codev customization (spec 250). See the note on the thread literal above. + role: null, + parentThreadId: null, ++ codevGate: null, ++ gateRevision: 0, + titleRegeneration: null, + session: { + threadId: ThreadId.make("thread-1"), +diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +index 31e6db929..24aafb574 100644 +--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts ++++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +@@ -1,4 +1,5 @@ + import { ++ CodevGate, + ChatAttachment, + CheckpointRef, + IsoDateTime, +@@ -91,6 +92,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), ++ codevGate: Schema.NullOr(Schema.fromJsonString(CodevGate)), + }), + ); + const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( +@@ -427,6 +429,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -466,6 +470,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -507,6 +513,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -952,6 +960,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -1717,6 +1727,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + // client receives instead of a key it has to infer. + role: row.role ?? null, + parentThreadId: row.parentThreadId ?? null, ++ codevGate: row.codevGate ?? null, ++ gateRevision: row.gateRevision ?? 0, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -1929,6 +1941,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + : { linkedPullRequest: row.linkedPullRequest }), + role: row.role ?? null, + parentThreadId: row.parentThreadId ?? null, ++ codevGate: row.codevGate ?? null, ++ gateRevision: row.gateRevision ?? 0, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2070,6 +2084,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + : { linkedPullRequest: row.linkedPullRequest }), + role: row.role ?? null, + parentThreadId: row.parentThreadId ?? null, ++ codevGate: row.codevGate ?? null, ++ gateRevision: row.gateRevision ?? 0, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2220,6 +2236,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + : { linkedPullRequest: row.linkedPullRequest }), + role: row.role ?? null, + parentThreadId: row.parentThreadId ?? null, ++ codevGate: row.codevGate ?? null, ++ gateRevision: row.gateRevision ?? 0, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +@@ -2504,6 +2522,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + : { linkedPullRequest: threadRow.value.linkedPullRequest }), + role: threadRow.value.role ?? null, + parentThreadId: threadRow.value.parentThreadId ?? null, ++ codevGate: threadRow.value.codevGate ?? null, ++ gateRevision: threadRow.value.gateRevision ?? 0, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, +@@ -2650,6 +2670,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { + : { linkedPullRequest: threadRow.value.linkedPullRequest }), + role: threadRow.value.role ?? null, + parentThreadId: threadRow.value.parentThreadId ?? null, ++ codevGate: threadRow.value.codevGate ?? null, ++ gateRevision: threadRow.value.gateRevision ?? 0, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, +diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts +index a32a45684..64ebd4495 100644 +--- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts +@@ -55,7 +55,21 @@ export interface OrchestrationEngineShape { + readonly dispatch: ( + command: OrchestrationCommand, + options?: { readonly origin?: OrchestrationClientOrigin }, +- ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; ++ ) => Effect.Effect< ++ { ++ readonly sequence: number; ++ /** ++ * Codev customization (spec 250). The events this dispatch committed. ++ * ++ * A caller that needs a value the SERVER allocated for its own write — ++ * the gate's revision — cannot re-read it from the projection without ++ * racing a concurrent writer, because the row only holds the latest. ++ */ ++ readonly events: ReadonlyArray; ++ }, ++ OrchestrationDispatchError, ++ never ++ >; + + /** + * Stream persisted domain events in dispatch order. +diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts +index 9103b862b..cd312775e 100644 +--- a/apps/server/src/orchestration/decider.ts ++++ b/apps/server/src/orchestration/decider.ts +@@ -1,4 +1,5 @@ + import { ++ CodevGateWriteError, + EventId, + type OrchestrationCommand, + type OrchestrationEvent, +@@ -187,7 +188,10 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ + readonly readModel: OrchestrationReadModel; + }): Effect.fn.Return< + ReadonlyArray, +- OrchestrationCommandInvariantError | CodevHierarchyInvalidError | PlatformError.PlatformError, ++ | OrchestrationCommandInvariantError ++ | CodevHierarchyInvalidError ++ | CodevGateWriteError ++ | PlatformError.PlatformError, + Crypto.Crypto + > { + let nextReadModel = readModel; +@@ -221,7 +225,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + readonly readModel: OrchestrationReadModel; + }): Effect.fn.Return< + DecideOrchestrationCommandResult, +- OrchestrationCommandInvariantError | CodevHierarchyInvalidError | PlatformError.PlatformError, ++ | OrchestrationCommandInvariantError ++ | CodevHierarchyInvalidError ++ | CodevGateWriteError ++ | PlatformError.PlatformError, + Crypto.Crypto + > { + switch (command.type) { +@@ -398,6 +405,84 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + }; + } + ++ /** ++ * Codev customization (spec 250). Gate writes, with a SERVER-ALLOCATED ++ * monotonic revision. ++ * ++ * One rule covers both allocation and stale-rejection, which review round 1 ++ * correctly said could not both be true as originally written: ++ * ++ * revision absent the normal path. Allocate `gateRevision + 1` from the ++ * read model this command is being decided against. ++ * Commands are decided serially against the authoritative ++ * read model, so two concurrent writers get two different ++ * numbers without any locking here. ++ * revision present a replay, or a write from an older connection. Applied ++ * only if it EXCEEDS the current mark. Equal is refused: ++ * two writers that computed the same number are colliding, ++ * not agreeing. ++ * ++ * The mark is a high-water mark on the thread, not a field inside the gate ++ * block, so it survives a clear. That is what stops a stale write resurrecting ++ * a gate a human already answered. ++ */ ++ case "codev.gate.set": ++ case "codev.gate.clear": { ++ const thread = yield* requireThread({ ++ readModel, ++ command, ++ threadId: command.threadId, ++ }); ++ const currentRevision = thread.gateRevision ?? 0; ++ ++ if (command.revision !== undefined && command.revision <= currentRevision) { ++ return yield* new CodevGateWriteError({ ++ reason: "CODEV_GATE_REVISION_STALE", ++ message: ++ `Gate write for thread '${command.threadId}' carries revision ${command.revision}, ` + ++ `at or below the current mark ${currentRevision}. Equal is refused as well as lower: ` + ++ `two writers that computed the same number are colliding, not agreeing.`, ++ currentRevision, ++ }); ++ } ++ ++ const gateRevision = command.revision ?? currentRevision + 1; ++ const occurredAt = command.createdAt; ++ ++ if (command.type === "codev.gate.clear") { ++ return { ++ ...(yield* withEventBase({ ++ aggregateKind: "thread", ++ aggregateId: command.threadId, ++ occurredAt, ++ commandId: command.commandId, ++ })), ++ type: "codev.gate-cleared", ++ payload: { ++ threadId: command.threadId, ++ gateRevision, ++ updatedAt: occurredAt, ++ }, ++ }; ++ } ++ ++ return { ++ ...(yield* withEventBase({ ++ aggregateKind: "thread", ++ aggregateId: command.threadId, ++ occurredAt, ++ commandId: command.commandId, ++ })), ++ type: "codev.gate-set", ++ payload: { ++ threadId: command.threadId, ++ gate: command.gate, ++ gateRevision, ++ updatedAt: occurredAt, ++ }, ++ }; ++ } ++ + case "thread.delete": { + yield* requireThread({ + readModel, +diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts +index 50697014d..38c090e64 100644 +--- a/apps/server/src/orchestration/projector.test.ts ++++ b/apps/server/src/orchestration/projector.test.ts +@@ -89,6 +89,11 @@ describe("orchestration projector", () => { + // defaults both to null, which is what "Codev did not create this" means. + role: null, + parentThreadId: null, ++ // Codev customization (spec 250). A new thread has no gate and sits at ++ // revision 0: the mark is a high-water mark, so starting anywhere else ++ // would make the first real write look stale. ++ codevGate: null, ++ gateRevision: 0, + latestTurn: null, + createdAt: now, + updatedAt: now, +diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts +index c1684c08b..662efc96f 100644 +--- a/apps/server/src/orchestration/projector.ts ++++ b/apps/server/src/orchestration/projector.ts +@@ -1,5 +1,7 @@ + import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; + import { ++ CodevGateClearedPayload, ++ CodevGateSetPayload, + OrchestrationCheckpointSummary, + OrchestrationMessage, + OrchestrationSession, +@@ -303,6 +305,11 @@ export function projectEvent( + // silently flatten it while every count still matched. + role: payload.role, + parentThreadId: payload.parentThreadId, ++ // A thread is created with no gate and at revision 0. The mark is a ++ // high-water mark, so starting anywhere else would let the first real ++ // write look stale. ++ codevGate: null, ++ gateRevision: 0, + latestTurn: null, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, +@@ -329,6 +336,37 @@ export function projectEvent( + }; + }); + ++ /** ++ * Codev customization (spec 250). The gate block and its high-water mark. ++ * ++ * The mark is written on BOTH events, including the clear. That is the whole ++ * mechanism: it outlives the block it described, so a stale write arriving ++ * after a human answered cannot resurrect the gate. ++ */ ++ case "codev.gate-set": ++ return decodeForEvent(CodevGateSetPayload, event.payload, event.type, "payload").pipe( ++ Effect.map((payload) => ({ ++ ...nextBase, ++ threads: updateThread(nextBase.threads, payload.threadId, { ++ codevGate: payload.gate, ++ gateRevision: payload.gateRevision, ++ updatedAt: payload.updatedAt, ++ }), ++ })), ++ ); ++ ++ case "codev.gate-cleared": ++ return decodeForEvent(CodevGateClearedPayload, event.payload, event.type, "payload").pipe( ++ Effect.map((payload) => ({ ++ ...nextBase, ++ threads: updateThread(nextBase.threads, payload.threadId, { ++ codevGate: null, ++ gateRevision: payload.gateRevision, ++ updatedAt: payload.updatedAt, ++ }), ++ })), ++ ); ++ + case "thread.deleted": + return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ +diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts +index 26bb71f1f..234b81d26 100644 +--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts ++++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts +@@ -14,12 +14,14 @@ import { + ProjectionThreadRepository, + type ProjectionThreadRepositoryShape, + } from "../Services/ProjectionThreads.ts"; +-import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; ++import { CodevGate, ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; + + const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ + modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), ++ // Codev customization (spec 250). Stored as JSON like linkedPullRequest. ++ codevGate: Schema.NullOr(Schema.fromJsonString(CodevGate)), + }), + ); + type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +@@ -43,6 +45,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + linked_pull_request_json, + codev_role, + codev_parent_thread_id, ++ codev_gate_json, ++ codev_gate_revision, + latest_turn_id, + created_at, + updated_at, +@@ -73,6 +77,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, + ${row.role ?? null}, + ${row.parentThreadId ?? null}, ++ ${row.codevGate === undefined || row.codevGate === null ? null : JSON.stringify(row.codevGate)}, ++ ${row.gateRevision ?? 0}, + ${row.latestTurnId}, + ${row.createdAt}, + ${row.updatedAt}, +@@ -103,6 +109,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + linked_pull_request_json = excluded.linked_pull_request_json, + codev_role = excluded.codev_role, + codev_parent_thread_id = excluded.codev_parent_thread_id, ++ codev_gate_json = excluded.codev_gate_json, ++ -- The mark only ever increases. An upsert that carried a lower one ++ -- would undo the very thing the mark exists to guarantee, and upserts ++ -- happen on every projection write, not only on gate writes. ++ codev_gate_revision = MAX(projection_threads.codev_gate_revision, excluded.codev_gate_revision), + latest_turn_id = excluded.latest_turn_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, +@@ -140,6 +151,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +@@ -179,6 +192,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { + linked_pull_request_json AS "linkedPullRequest", + codev_role AS "role", + codev_parent_thread_id AS "parentThreadId", ++ codev_gate_json AS "codevGate", ++ codev_gate_revision AS "gateRevision", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", +diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts +index fab83e90e..adfafcfb1 100644 +--- a/apps/server/src/persistence/Services/ProjectionThreads.ts ++++ b/apps/server/src/persistence/Services/ProjectionThreads.ts +@@ -15,14 +15,15 @@ import { + ProviderInteractionMode, + RuntimeMode, + ThreadLinkedPullRequest, ++ CodevGate, + CodevThreadRole, + ThreadId, + TurnId, + } from "@t3tools/contracts"; + import * as Option from "effect/Option"; ++import * as Effect from "effect/Effect"; + import * as Schema from "effect/Schema"; + import * as Context from "effect/Context"; +-import type * as Effect from "effect/Effect"; + + import type { ProjectionRepositoryError } from "../Errors.ts"; + +@@ -44,6 +45,12 @@ export const ProjectionThread = Schema.Struct({ + */ + role: Schema.optional(Schema.NullOr(CodevThreadRole)), + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ++ /** ++ * Codev customization (spec 250). The gate block, and the high-water mark ++ * stored beside it rather than inside it — the mark must outlive a clear. ++ */ ++ codevGate: Schema.optional(Schema.NullOr(CodevGate)), ++ gateRevision: Schema.optional(NonNegativeInt).pipe(Schema.withDecodingDefault(Effect.succeed(0))), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts +index 74a4de594..99450917b 100644 +--- a/apps/server/src/relay/AgentAwarenessRelay.test.ts ++++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts +@@ -472,7 +472,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { + + const orchestrationEngine = { + readEvents: () => Stream.empty, +- dispatch: () => Effect.succeed({ sequence: 1 }), ++ dispatch: () => Effect.succeed({ sequence: 1, events: [] }), + streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngineShape; +@@ -664,7 +664,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { + }), + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, +- dispatch: () => Effect.succeed({ sequence: 1 }), ++ dispatch: () => Effect.succeed({ sequence: 1, events: [] }), + streamDomainEvents: Stream.fromQueue(events), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngineShape), +diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts +index a9a2c3fa1..70ccf876b 100644 +--- a/apps/server/src/server.test.ts ++++ b/apps/server/src/server.test.ts +@@ -788,7 +788,7 @@ const buildAppUnderTest = (options?: { + Layer.provide( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, +- dispatch: () => Effect.succeed({ sequence: 0 }), ++ dispatch: () => Effect.succeed({ sequence: 0, events: [] }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + ...options?.layers?.orchestrationEngine, +@@ -5238,7 +5238,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + detail: "thread creation failed", + }), + ) +- : Effect.succeed({ sequence: 1 }), ++ : Effect.succeed({ sequence: 1, events: [] }), + ), + ), + }, +@@ -6154,7 +6154,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + }), + }, + orchestrationEngine: { +- dispatch: () => Effect.succeed({ sequence: 7 }), ++ dispatch: () => Effect.succeed({ sequence: 7, events: [] }), + readEvents: () => Stream.empty, + }, + checkpointDiffQuery: { +@@ -7051,7 +7051,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7126,7 +7126,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + if (command.type === "thread.archive") { + archived = true; + } +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7199,7 +7199,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7253,7 +7253,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7319,7 +7319,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7387,7 +7387,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + }, + projectionSnapshotQuery: { +@@ -7446,7 +7446,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + }), + ); + } +- return Effect.succeed({ sequence: dispatchedCommands.length }); ++ return Effect.succeed({ sequence: dispatchedCommands.length, events: [] }); + }, + }, + projectionSnapshotQuery: { +@@ -7518,7 +7518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + if (command.type === "thread.session.stop") { + return Effect.die(new Error("simulated archive stop defect")); + } +- return Effect.succeed({ sequence: dispatchedCommands.length }); ++ return Effect.succeed({ sequence: dispatchedCommands.length, events: [] }); + }, + }, + projectionSnapshotQuery: { +@@ -7658,7 +7658,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + readEvents: () => Stream.empty, + }, +@@ -7807,7 +7807,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + readEvents: () => Stream.empty, + }, +@@ -7907,7 +7907,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + readEvents: () => Stream.empty, + }, +@@ -8027,7 +8027,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + + return Effect.sync(() => { + dispatchedCommands.push(command); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }); + }, + readEvents: () => Stream.empty, +@@ -8117,7 +8117,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); +- return { sequence: dispatchedCommands.length }; ++ return { sequence: dispatchedCommands.length, events: [] }; + }), + readEvents: () => Stream.empty, + }, +@@ -8228,7 +8228,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { + }), + ); + } +- return Effect.succeed({ sequence: dispatchedCommands.length }); ++ return Effect.succeed({ sequence: dispatchedCommands.length, events: [] }); + }, + readEvents: () => Stream.empty, + }, +diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts +index 485cd5bb0..3ab9d7940 100644 +--- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts ++++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts +@@ -132,7 +132,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => +- Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), ++ Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length, events: [] })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { +@@ -202,7 +202,7 @@ it.effect( + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( +- Effect.as({ sequence: dispatched.length }), ++ Effect.as({ sequence: dispatched.length, events: [] }), + ), + }).pipe( + Effect.tap(() => +@@ -247,7 +247,7 @@ it.effect("retries failed projections and continues after a persistent failure", + } + return command.threadId === persistent.id + ? Effect.fail(failure) +- : Effect.succeed({ sequence: attempted.length }); ++ : Effect.succeed({ sequence: attempted.length, events: [] }); + }, + }).pipe( + Effect.tap(() => +diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts +index e3f7e482b..e120f3125 100644 +--- a/apps/server/src/serverRuntimeStartup.test.ts ++++ b/apps/server/src/serverRuntimeStartup.test.ts +@@ -167,7 +167,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( +- Effect.as({ sequence: 1 }), ++ Effect.as({ sequence: 1, events: [] }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), +@@ -212,7 +212,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( +- Effect.as({ sequence: 1 }), ++ Effect.as({ sequence: 1, events: [] }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), +@@ -263,7 +263,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( +- Effect.as({ sequence: 1 }), ++ Effect.as({ sequence: 1, events: [] }), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), +diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts +index 226c82cdb..ae895c634 100644 +--- a/apps/server/src/ws.ts ++++ b/apps/server/src/ws.ts +@@ -34,6 +34,8 @@ import { + OrchestrationGetSnapshotError, + OrchestrationSearchThreadsError, + OrchestrationGetTurnDiffError, ++ CODEV_WS_METHODS, ++ CodevGateWriteError, + ORCHESTRATION_WS_METHODS, + type ProjectId, + type ProjectEntriesFailure, +@@ -1172,6 +1174,68 @@ const makeWsRpcLayer = ( + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + return WsRpcGroup.of({ ++ /** ++ * Codev customization (spec 250). Gate writes. ++ * ++ * Authorization happens in `observeRpcEffect` via the scope map, which ++ * requires `codev:gate-write` for this method — a scope deliberately ++ * absent from `AuthStandardClientScopes` and from the token allowlist, so ++ * an ordinary operator's refusal names it rather than reading as a ++ * generic 403. ++ * ++ * The response carries the allocated revision, read from the event this ++ * dispatch produced. `dispatchCommand` has no revision to return, which ++ * is one of the reasons the gate does not travel on it. ++ */ ++ [CODEV_WS_METHODS.gateWrite]: (command) => ++ observeRpcEffect( ++ CODEV_WS_METHODS.gateWrite, ++ Effect.gen(function* () { ++ const { events } = yield* dispatchFromClient(command).pipe( ++ Effect.mapError((cause) => ++ // The decider's own refusal — a stale revision, or no such ++ // thread — arrives here already named. Anything else is ++ // reported as the thread lookup failing rather than being ++ // given a reason it did not have. ++ cause instanceof CodevGateWriteError ++ ? cause ++ : new CodevGateWriteError({ ++ reason: "CODEV_GATE_THREAD_NOT_FOUND", ++ message: `Gate write refused: ${String(cause)}`, ++ cause, ++ }), ++ ), ++ ); ++ ++ // The event THIS dispatch committed, handed back by the engine. ++ // Re-reading the thread row instead would race a concurrent writer: ++ // both would see the later revision and one would be told a number ++ // that was never allocated to it. ++ const committed = events.find( ++ (event) => event.type === "codev.gate-set" || event.type === "codev.gate-cleared", ++ ); ++ if ( ++ committed === undefined || ++ (committed.type !== "codev.gate-set" && committed.type !== "codev.gate-cleared") ++ ) { ++ // Reported as unconfirmed, never as applied. The write may well ++ // have landed; what we cannot do is tell the caller which ++ // revision it got, and a guessed revision is worse than none. ++ return yield* new CodevGateWriteError({ ++ reason: "CODEV_GATE_THREAD_NOT_FOUND", ++ message: ++ "Gate write committed but its event could not be read back; the revision is " + ++ "unconfirmed. Re-read the thread rather than assuming this write applied.", ++ }); ++ } ++ ++ return { ++ threadId: committed.payload.threadId, ++ gateRevision: committed.payload.gateRevision, ++ cleared: committed.type === "codev.gate-cleared", ++ }; ++ }), ++ ), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.dispatchCommand, +diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts +index a0d3d89b7..ed642b909 100644 +--- a/packages/contracts/src/auth.ts ++++ b/packages/contracts/src/auth.ts +@@ -81,6 +81,16 @@ export const AuthAccessReadScope = "access:read" as const; + export const AuthAccessWriteScope = "access:write" as const; + export const AuthRelayReadScope = "relay:read" as const; + export const AuthRelayWriteScope = "relay:write" as const; ++/** ++ * Codev customization (spec 250). Writing a porch gate block. ++ * ++ * Deliberately NOT in `AuthStandardClientScopes` or `AuthAdministrativeScopes`, and not in ++ * the token allowlist in `apps/server/src/auth/http.ts`. It is issued out of band to exactly ++ * one credential — `codev-agent` — and never to a thread. Adding it to either set would grant ++ * gate-writing to precisely the callers it exists to exclude, which is the whole point of ++ * giving gate writes their own RPC method rather than routing them through `dispatchCommand`. ++ */ ++export const AuthCodevGateWriteScope = "codev:gate-write" as const; + export const AuthEnvironmentScope = Schema.Literals([ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, +@@ -90,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([ + AuthAccessWriteScope, + AuthRelayReadScope, + AuthRelayWriteScope, ++ AuthCodevGateWriteScope, + ]); + export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; + export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index 34faa73f5..be111a3dd 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -35,6 +35,19 @@ export const ORCHESTRATION_WS_METHODS = { + subscribeThread: "orchestration.subscribeThread", + } as const; + ++/** ++ * Codev customization (spec 250). Gate writes travel their OWN RPC method. ++ * ++ * `RpcAuthorization.ts` authorizes the *method*, not the command type — it maps ++ * `orchestration.dispatchCommand` as a whole to `orchestration:operate`. So ++ * routing `codev.gate.set` through `dispatchCommand` and hoping to scope it ++ * separately is not expressible: every operator would reach it. A separate ++ * method is the only place a separate scope can attach. ++ */ ++export const CODEV_WS_METHODS = { ++ gateWrite: "codev.gateWrite", ++} as const; ++ + export const ProviderApprovalPolicy = Schema.Literals([ + "untrusted", + "on-failure", +@@ -395,6 +408,65 @@ export const ThreadLinkedPullRequest = Schema.Struct({ + }); + export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; + ++/** ++ * Codev customization (spec 250). One choice in a gate's structured question (#128). ++ * ++ * `consequence` is required: a choice whose outcome is unstated asks a human to ++ * pick blind, which is the failure the structured request exists to remove. ++ */ ++export const CodevGateChoice = Schema.Struct({ ++ label: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), ++ consequence: TrimmedNonEmptyString.check(Schema.isMaxLength(2000)), ++ recommended: Schema.optional(Schema.Boolean), ++}); ++export type CodevGateChoice = typeof CodevGateChoice.Type; ++ ++/** ++ * Codev customization (spec 250). A porch gate awaiting a human, as first-class ++ * thread state. ++ * ++ * Spec 146 wrote the gate name into the thread TITLE, because there was nowhere ++ * else to put it. This is that nowhere-else. It is deliberately separate from ++ * `hasPendingApprovals`, which is provider tool approvals: a builder blocked on ++ * `plan-approval` and one waiting on a tool call need different things from a ++ * human, and one flag covering both is one flag nobody can act on. ++ * ++ * Bounded at the schema boundary rather than by convention. An oversize or ++ * malformed payload is refused whole; a gate that partially applied would leave ++ * a human looking at half a question. ++ */ ++export const CodevGate = Schema.Struct({ ++ /** The porch gate name, e.g. `plan-approval`. Never the thread title again. */ ++ gateName: TrimmedNonEmptyString.check(Schema.isMaxLength(120)), ++ requestedAt: IsoDateTime, ++ /** #128's structured question. Single-line: it is rendered as a heading. */ ++ question: Schema.optional( ++ Schema.NullOr( ++ TrimmedNonEmptyString.check( ++ Schema.isMaxLength(500), ++ Schema.makeFilter((value) => ++ /[\r\n]/.test(value) ++ ? "the gate question must be a single line: it is rendered as a heading" ++ : undefined, ++ ), ++ ), ++ ), ++ ), ++ /** One to five. Zero choices is not a question; more than five is not a gate. */ ++ choices: Schema.optional( ++ Schema.NullOr(Schema.Array(CodevGateChoice).check(Schema.isMinLength(1), Schema.isMaxLength(5))), ++ ), ++ /** Last relevant output. Multi-line is fine here; this one is rendered as a block. */ ++ terminalExcerpt: Schema.optional(Schema.NullOr(Schema.String.check(Schema.isMaxLength(8000)))), ++}).check( ++ Schema.makeFilter((gate) => ++ (gate.choices ?? []).filter((choice) => choice.recommended === true).length <= 1 ++ ? undefined ++ : "at most one choice may be marked recommended: two recommendations is no recommendation", ++ ), ++); ++export type CodevGate = typeof CodevGate.Type; ++ + /** + * Codev customization (spec 250). Which side of the Codev hierarchy a thread is. + * +@@ -434,6 +506,17 @@ export const OrchestrationThread = Schema.Struct({ + */ + role: Schema.optional(Schema.NullOr(CodevThreadRole)), + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ++ /** ++ * Codev customization (spec 250). The gate awaiting a human, or none. ++ * ++ * `gateRevision` is stored SEPARATELY and is a non-nullable high-water mark ++ * that only ever increases — including across a clear, because clearing is a ++ * write like any other. Keeping it outside the block is what lets a stale write ++ * be rejected after the gate is gone: if the mark lived inside `codevGate` it ++ * would vanish with it, and the next stale write would resurrect the gate. ++ */ ++ codevGate: Schema.optional(Schema.NullOr(CodevGate)), ++ gateRevision: Schema.optional(NonNegativeInt).pipe(Schema.withDecodingDefault(Effect.succeed(0))), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), +@@ -521,6 +604,17 @@ export const OrchestrationThreadShell = Schema.Struct({ + */ + role: Schema.optional(Schema.NullOr(CodevThreadRole)), + parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), ++ /** ++ * Codev customization (spec 250). The gate awaiting a human, or none. ++ * ++ * `gateRevision` is stored SEPARATELY and is a non-nullable high-water mark ++ * that only ever increases — including across a clear, because clearing is a ++ * write like any other. Keeping it outside the block is what lets a stale write ++ * be rejected after the gate is gone: if the mark lived inside `codevGate` it ++ * would vanish with it, and the next stale write would resurrect the gate. ++ */ ++ codevGate: Schema.optional(Schema.NullOr(CodevGate)), ++ gateRevision: Schema.optional(NonNegativeInt).pipe(Schema.withDecodingDefault(Effect.succeed(0))), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), +@@ -989,6 +1083,65 @@ const ThreadSessionStopCommand = Schema.Struct({ + onlyIfSettled: Schema.optional(Schema.Boolean), + }); + ++/** ++ * Codev customization (spec 250). Gate writes, INTERNAL ONLY. ++ * ++ * Deliberately absent from `DispatchableClientOrchestrationCommand` and ++ * `ClientOrchestrationCommand`: those unions ARE the `dispatchCommand` payload, ++ * so adding these there would hand gate-writing to every holder of ++ * `orchestration:operate` and silently bypass `codev:gate-write` — undoing this ++ * phase's entire point. `ThreadSessionSetCommand` is the existing precedent for ++ * an internal-only command. ++ * ++ * `revision` is OPTIONAL, and that is what lets allocation and stale-rejection ++ * coexist: ++ * ++ * absent the normal path. The server allocates `gateRevision + 1` and ++ * returns it. The writer never invents one, because a counter held in ++ * a writer's memory resets on restart, and a reset counter renders ++ * every later gate as "no gate pending" — a false negative exactly ++ * where a human is waiting. ++ * present a replay, or a write from an older connection. Applied only if it ++ * EXCEEDS the current mark. Equal is refused rather than treated as ++ * idempotent: two writers that computed the same number are not ++ * agreeing, they are colliding. ++ */ ++const CodevGateSetCommand = Schema.Struct({ ++ type: Schema.Literal("codev.gate.set"), ++ commandId: CommandId, ++ threadId: ThreadId, ++ gate: CodevGate, ++ revision: Schema.optional(NonNegativeInt), ++ createdAt: IsoDateTime, ++}); ++ ++const CodevGateClearCommand = Schema.Struct({ ++ type: Schema.Literal("codev.gate.clear"), ++ commandId: CommandId, ++ threadId: ThreadId, ++ revision: Schema.optional(NonNegativeInt), ++ createdAt: IsoDateTime, ++}); ++ ++/** The `codev.gateWrite` RPC payload: one command, either shape. */ ++export const CodevGateWriteInput = Schema.Union([CodevGateSetCommand, CodevGateClearCommand]); ++export type CodevGateWriteInput = typeof CodevGateWriteInput.Type; ++ ++/** ++ * The RPC's own response, carrying the allocated revision. ++ * ++ * Returned here and not through `dispatchCommand`, which carries no revision. A ++ * gate write whose response cannot be read is reported by the caller as ++ * unconfirmed, never as applied. ++ */ ++export const CodevGateWriteResult = Schema.Struct({ ++ threadId: ThreadId, ++ gateRevision: NonNegativeInt, ++ /** True when the write cleared the gate rather than setting one. */ ++ cleared: Schema.Boolean, ++}); ++export type CodevGateWriteResult = typeof CodevGateWriteResult.Type; ++ + const DispatchableClientOrchestrationCommand = Schema.Union([ + ProjectCreateCommand, + ProjectMetaUpdateCommand, +@@ -1126,6 +1279,8 @@ const InternalOrchestrationCommand = Schema.Union([ + ThreadActivityAppendCommand, + ThreadRevertCompleteCommand, + ThreadTitleRegenerationCompleteCommand, ++ CodevGateSetCommand, ++ CodevGateClearCommand, + ]); + export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; + +@@ -1140,6 +1295,8 @@ export const OrchestrationEventType = Schema.Literals([ + "project.meta-updated", + "project.deleted", + "thread.created", ++ "codev.gate-set", ++ "codev.gate-cleared", + "thread.deleted", + "thread.archived", + "thread.unarchived", +@@ -1230,6 +1387,34 @@ export const ThreadCreatedPayload = Schema.Struct({ + updatedAt: IsoDateTime, + }); + ++/** ++ * Codev customization (spec 250). The gate set, carrying the revision the SERVER ++ * allocated. ++ * ++ * The revision travels on the event rather than being re-read from the row, ++ * because two concurrent writers must each learn their own number and the row ++ * only ever holds the latest. ++ */ ++export const CodevGateSetPayload = Schema.Struct({ ++ threadId: ThreadId, ++ gate: CodevGate, ++ gateRevision: NonNegativeInt, ++ updatedAt: IsoDateTime, ++}); ++ ++/** ++ * Codev customization (spec 250). The gate cleared. ++ * ++ * Clearing carries a revision too, and raises the high-water mark. That is what ++ * stops a stale write resurrecting a gate a human has already answered: the mark ++ * outlives the block it described. ++ */ ++export const CodevGateClearedPayload = Schema.Struct({ ++ threadId: ThreadId, ++ gateRevision: NonNegativeInt, ++ updatedAt: IsoDateTime, ++}); ++ + export const ThreadDeletedPayload = Schema.Struct({ + threadId: ThreadId, + deletedAt: IsoDateTime, +@@ -1468,6 +1653,17 @@ export const OrchestrationEvent = Schema.Union([ + type: Schema.Literal("thread.created"), + payload: ThreadCreatedPayload, + }), ++ // Codev customization (spec 250). ++ Schema.Struct({ ++ ...EventBaseFields, ++ type: Schema.Literal("codev.gate-set"), ++ payload: CodevGateSetPayload, ++ }), ++ Schema.Struct({ ++ ...EventBaseFields, ++ type: Schema.Literal("codev.gate-cleared"), ++ payload: CodevGateClearedPayload, ++ }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.deleted"), +@@ -1822,6 +2018,38 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( ++ "CodevGateWriteError", ++ { ++ reason: CodevGateWriteErrorReason, ++ message: TrimmedNonEmptyString, ++ /** The mark the server holds, so a stale writer can resynchronise rather than retry blind. */ ++ currentRevision: Schema.optional(NonNegativeInt), ++ cause: Schema.optional(Schema.Defect()), ++ }, ++) {} ++ + export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetTurnDiffError", + { +diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts +index 14363cfed..40308ad19 100644 +--- a/packages/contracts/src/rpc.ts ++++ b/packages/contracts/src/rpc.ts +@@ -62,6 +62,10 @@ import { + import { KeybindingsConfigError } from "./keybindings.ts"; + import { + ClientOrchestrationCommand, ++ CODEV_WS_METHODS, ++ CodevGateWriteError, ++ CodevGateWriteInput, ++ CodevGateWriteResult, + ORCHESTRATION_WS_METHODS, + OrchestrationDispatchCommandError, + OrchestrationGetFullThreadDiffError, +@@ -912,6 +916,20 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make( + }, + ); + ++/** ++ * Codev customization (spec 250). Gate writes, on their own method. ++ * ++ * Separate from `dispatchCommand` because `RpcAuthorization` authorizes the ++ * METHOD, not the command type. A gate command routed through `dispatchCommand` ++ * would be reachable by every holder of `orchestration:operate`, and no row in ++ * the scope map could say otherwise. ++ */ ++export const WsCodevGateWriteRpc = Rpc.make(CODEV_WS_METHODS.gateWrite, { ++ payload: CodevGateWriteInput, ++ success: CodevGateWriteResult, ++ error: Schema.Union([CodevGateWriteError, EnvironmentAuthorizationError]), ++}); ++ + export const WsOrchestrationGetWorkflowScriptRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getWorkflowScript, + { +@@ -1113,6 +1131,7 @@ export const WsRpcGroup = RpcGroup.make( + WsSubscribeBackgroundPolicyRpc, + WsSubscribeResourceTelemetryRpc, + WsOrchestrationDispatchCommandRpc, ++ WsCodevGateWriteRpc, + WsOrchestrationGetWorkflowScriptRpc, + WsOrchestrationGetTurnDiffRpc, + WsOrchestrationGetFullThreadDiffRpc, diff --git a/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch b/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch new file mode 100644 index 000000000..a8cb74884 --- /dev/null +++ b/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch @@ -0,0 +1,591 @@ +From 57d24ddcb3be0fe1b893948274dadc466c67b81e Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 18:05:59 -0600 +Subject: [PATCH 07/34] [Spec 250][Phase: phase_4] test: the revision rules, + the scope exclusions, the payload bounds +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +29 tests across three files, each aimed at a property a wrong +implementation would still pass most tests on. + +Revision (11): allocation comes from the CURRENT mark rather than zero, so +a writer that restarted and forgot everything still gets a higher number — +the property a writer-held counter breaks, and its failure mode is every +later gate rendering as "no gate pending", a false negative exactly where a +human is waiting. Clearing RAISES the mark. Criterion 10 is a direct test: +clear an approved gate, deliver a lower revision, and the gate does not +come back — which only holds because the mark outlived the block it +described. Equal is refused as well as lower. + +Scope (7): the tests are the EXCLUSIONS, because that is where this scope's +value is. Not in AuthStandardClientScopes, not in AuthAdministrativeScopes, +and it guards exactly one method. One test pins that dispatchCommand still +requires only orchestration:operate — which is the reason the gate cannot +ride on it. One asserts against the contract source that neither gate +command appears in the client unions, because that property is an absence +and an absence has no runtime value to inspect. + +Payload bounds (11): six choices, an empty choices array, two recommended +choices, a multi-line question, an empty gate name, a choice with no +consequence, and both size caps — all refused at the schema boundary, so a +gate never partially applies. Half a question is worse than no gate: it +looks answerable. A multi-line terminal excerpt IS allowed, because that +one renders as a block. + +Fork typecheck green; contracts 301 passed, server 2815 passed. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/auth/CodevGateScope.test.ts | 111 +++++++ + .../orchestration/decider.codevGate.test.ts | 277 ++++++++++++++++++ + packages/contracts/src/orchestration.test.ts | 129 ++++++++ + 3 files changed, 517 insertions(+) + create mode 100644 apps/server/src/auth/CodevGateScope.test.ts + create mode 100644 apps/server/src/orchestration/decider.codevGate.test.ts + +diff --git a/apps/server/src/auth/CodevGateScope.test.ts b/apps/server/src/auth/CodevGateScope.test.ts +new file mode 100644 +index 000000000..4f2c7ab38 +--- /dev/null ++++ b/apps/server/src/auth/CodevGateScope.test.ts +@@ -0,0 +1,111 @@ ++/** ++ * Codev customization (spec 250) — `codev:gate-write`. ++ * ++ * The scope's value is entirely in what it is NOT attached to. A scope that ends ++ * up in the standard client set, or in the token allowlist, grants gate-writing ++ * to exactly the callers it exists to exclude — and nothing about the code would ++ * look wrong. These are the exclusions, asserted. ++ */ ++ ++import { ++ AuthAdministrativeScopes, ++ AuthCodevGateWriteScope, ++ AuthEnvironmentScope, ++ AuthOrchestrationOperateScope, ++ AuthStandardClientScopes, ++ CODEV_WS_METHODS, ++ ORCHESTRATION_WS_METHODS, ++} from "@t3tools/contracts"; ++import * as Schema from "effect/Schema"; ++import { describe, expect, it } from "vite-plus/test"; ++ ++import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts"; ++ ++describe("codev:gate-write scope (spec 250)", () => { ++ it("is a real environment scope", () => { ++ const decode = Schema.decodeUnknownSync(AuthEnvironmentScope); ++ expect(decode(AuthCodevGateWriteScope)).toBe("codev:gate-write"); ++ }); ++ ++ it("is NOT in the standard client scope set", () => { ++ // Every ordinary client is issued this set. Membership here would hand gate ++ // writing to every connected UI. ++ expect([...AuthStandardClientScopes]).not.toContain(AuthCodevGateWriteScope); ++ }); ++ ++ it("is NOT in the administrative scope set either", () => { ++ // Administrative is "an operator with full reach", which is still not the ++ // single out-of-band credential this scope is for. ++ expect([...AuthAdministrativeScopes]).not.toContain(AuthCodevGateWriteScope); ++ }); ++ ++ it("guards the gate RPC method, and only that method", () => { ++ expect(requiredScopeForRpcMethod(CODEV_WS_METHODS.gateWrite)).toBe(AuthCodevGateWriteScope); ++ ++ const guarded = Object.entries(RPC_REQUIRED_SCOPES) ++ .filter(([, scope]) => scope === AuthCodevGateWriteScope) ++ .map(([method]) => method); ++ expect(guarded).toEqual([CODEV_WS_METHODS.gateWrite]); ++ }); ++ ++ /** ++ * The reason the gate has its own method at all. ++ * ++ * `RPC_REQUIRED_SCOPES` maps the METHOD, not the command type. If gate writes ++ * rode on `dispatchCommand`, this row is the only thing that could have ++ * authorized them — and it says `orchestration:operate`, which every operator ++ * holds. ++ */ ++ it("dispatchCommand still requires only orchestration:operate, which is why the gate cannot ride on it", () => { ++ expect(requiredScopeForRpcMethod(ORCHESTRATION_WS_METHODS.dispatchCommand)).toBe( ++ AuthOrchestrationOperateScope, ++ ); ++ expect(requiredScopeForRpcMethod(ORCHESTRATION_WS_METHODS.dispatchCommand)).not.toBe( ++ AuthCodevGateWriteScope, ++ ); ++ }); ++ ++ it("a caller holding only orchestration:operate does not hold the gate scope", () => { ++ const operatorScopes = [...AuthStandardClientScopes]; ++ expect(operatorScopes).toContain(AuthOrchestrationOperateScope); ++ expect(operatorScopes).not.toContain(AuthCodevGateWriteScope); ++ }); ++}); ++ ++describe("codev gate commands stay off the dispatchCommand payload (spec 250)", () => { ++ /** ++ * Asserted against the contract SOURCE, because the property is an absence and ++ * an absence has no runtime value to inspect. If `codev.gate.set` ever appears ++ * in either client union, every holder of `orchestration:operate` can write ++ * gates and the scope above becomes decorative. ++ */ ++ it("neither gate command appears in the client command unions", async () => { ++ const { readFileSync } = await import("node:fs"); ++ const { fileURLToPath } = await import("node:url"); ++ const source = readFileSync( ++ fileURLToPath(new URL("../../../../packages/contracts/src/orchestration.ts", import.meta.url)), ++ "utf8", ++ ); ++ ++ const unionOf = (name: string) => { ++ const start = source.indexOf(`const ${name} = Schema.Union([`); ++ expect(start, `${name} must exist`).toBeGreaterThan(-1); ++ return source.slice(start, source.indexOf("]);", start)); ++ }; ++ ++ for (const union of ["DispatchableClientOrchestrationCommand", "ClientOrchestrationCommand"]) { ++ const body = unionOf(union); ++ expect(body, `${union} must not carry CodevGateSetCommand`).not.toContain( ++ "CodevGateSetCommand", ++ ); ++ expect(body, `${union} must not carry CodevGateClearCommand`).not.toContain( ++ "CodevGateClearCommand", ++ ); ++ } ++ ++ // And they ARE reachable internally, or the decider could never see them. ++ const internal = unionOf("InternalOrchestrationCommand"); ++ expect(internal).toContain("CodevGateSetCommand"); ++ expect(internal).toContain("CodevGateClearCommand"); ++ }); ++}); +diff --git a/apps/server/src/orchestration/decider.codevGate.test.ts b/apps/server/src/orchestration/decider.codevGate.test.ts +new file mode 100644 +index 000000000..f4ab3b673 +--- /dev/null ++++ b/apps/server/src/orchestration/decider.codevGate.test.ts +@@ -0,0 +1,277 @@ ++/** ++ * Codev customization (spec 250) — the gate's server-allocated revision. ++ * ++ * The revision is the whole mechanism, and every property below is one a wrong ++ * implementation would still pass most tests on: ++ * ++ * - it is allocated by the SERVER, so a writer that restarts cannot reset it; ++ * - it survives a clear, so a stale write cannot resurrect an answered gate; ++ * - EQUAL is refused as well as lower, because two writers that computed the ++ * same number are colliding, not agreeing. ++ */ ++ ++import { ++ CommandId, ++ ProjectId, ++ ProviderInstanceId, ++ ThreadId, ++ CodevGateWriteError, ++ type CodevGate, ++ type OrchestrationEvent, ++ type OrchestrationReadModel, ++ type OrchestrationThread, ++} from "@t3tools/contracts"; ++import * as NodeServices from "@effect/platform-node/NodeServices"; ++import { assert, expect, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++ ++import { decideOrchestrationCommand } from "./decider.ts"; ++ ++const NOW = "2026-01-01T00:00:00.000Z"; ++ ++const GATE: CodevGate = { ++ gateName: "plan-approval", ++ requestedAt: NOW, ++ question: "Delete the legacy table, or keep it for audit purposes?", ++ choices: [ ++ { label: "Delete it", consequence: "Migrate references and drop it.", recommended: true }, ++ { label: "Keep it", consequence: "Retain the table and document the dependency." }, ++ ], ++}; ++ ++function thread(input: { ++ readonly id?: string; ++ readonly gateRevision?: number; ++ readonly codevGate?: CodevGate | null; ++}): OrchestrationThread { ++ return { ++ id: ThreadId.make(input.id ?? "thread-1"), ++ projectId: ProjectId.make("project-1"), ++ title: "Thread", ++ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, ++ runtimeMode: "full-access", ++ interactionMode: "default", ++ branch: null, ++ worktreePath: null, ++ role: null, ++ parentThreadId: null, ++ codevGate: input.codevGate ?? null, ++ gateRevision: input.gateRevision ?? 0, ++ latestTurn: null, ++ createdAt: NOW, ++ updatedAt: NOW, ++ archivedAt: null, ++ settledOverride: null, ++ settledAt: null, ++ snoozedUntil: null, ++ snoozedAt: null, ++ pinnedAt: null, ++ pinOrderKey: null, ++ deletedAt: null, ++ messages: [], ++ proposedPlans: [], ++ activities: [], ++ checkpoints: [], ++ session: null, ++ } as OrchestrationThread; ++} ++ ++const readModel = (threads: ReadonlyArray): OrchestrationReadModel => ++ ({ snapshotSequence: 0, projects: [], threads, updatedAt: NOW }) as OrchestrationReadModel; ++ ++const setGate = (input: { readonly commandId: string; readonly revision?: number }) => ({ ++ type: "codev.gate.set" as const, ++ commandId: CommandId.make(input.commandId), ++ threadId: ThreadId.make("thread-1"), ++ gate: GATE, ++ ...(input.revision === undefined ? {} : { revision: input.revision }), ++ createdAt: NOW, ++}); ++ ++const clearGate = (input: { readonly commandId: string; readonly revision?: number }) => ({ ++ type: "codev.gate.clear" as const, ++ commandId: CommandId.make(input.commandId), ++ threadId: ThreadId.make("thread-1"), ++ ...(input.revision === undefined ? {} : { revision: input.revision }), ++ createdAt: NOW, ++}); ++ ++const decide = (command: unknown, model: OrchestrationReadModel) => ++ Effect.map( ++ decideOrchestrationCommand({ command: command as never, readModel: model }), ++ (event) => (Array.isArray(event) ? event : [event]) as ReadonlyArray, ++ ); ++ ++const refusal = (command: unknown, model: OrchestrationReadModel) => ++ Effect.gen(function* () { ++ const outcome = yield* Effect.result(decide(command, model)); ++ assert.strictEqual(outcome._tag, "Failure", "the write must be refused"); ++ const error = (outcome as { failure: unknown }).failure; ++ assert.ok(error instanceof CodevGateWriteError, `expected CodevGateWriteError, got ${String(error)}`); ++ return error; ++ }); ++ ++it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { ++ it.effect("allocates the next revision when the command carries none", () => ++ Effect.gen(function* () { ++ const events = yield* decide(setGate({ commandId: "cmd-1" }), readModel([thread({})])); ++ expect(events[0]?.type).toBe("codev.gate-set"); ++ if (events[0]?.type === "codev.gate-set") { ++ // Allocated, not echoed: the command carried no revision at all. ++ expect(events[0].payload.gateRevision).toBe(1); ++ expect(events[0].payload.gate.gateName).toBe("plan-approval"); ++ } ++ }), ++ ); ++ ++ it.effect("allocates from the CURRENT mark, not from zero", () => ++ Effect.gen(function* () { ++ const events = yield* decide( ++ setGate({ commandId: "cmd-2" }), ++ readModel([thread({ gateRevision: 41 })]), ++ ); ++ if (events[0]?.type === "codev.gate-set") { ++ expect(events[0].payload.gateRevision).toBe(42); ++ } ++ }), ++ ); ++ ++ /** ++ * The property a writer-held counter would break. `codev-agent` restarting and ++ * starting again from 1 would render every later gate as "no gate pending" — a ++ * false negative exactly where a human is waiting. ++ */ ++ it.effect("a writer that forgot everything still gets a higher number", () => ++ Effect.gen(function* () { ++ const events = yield* decide( ++ setGate({ commandId: "cmd-restart" }), ++ readModel([thread({ gateRevision: 9 })]), ++ ); ++ if (events[0]?.type === "codev.gate-set") { ++ expect(events[0].payload.gateRevision).toBe(10); ++ } ++ }), ++ ); ++ ++ it.effect("clearing raises the mark rather than resetting it", () => ++ Effect.gen(function* () { ++ const events = yield* decide( ++ clearGate({ commandId: "cmd-clear" }), ++ readModel([thread({ gateRevision: 5, codevGate: GATE })]), ++ ); ++ expect(events[0]?.type).toBe("codev.gate-cleared"); ++ if (events[0]?.type === "codev.gate-cleared") { ++ expect(events[0].payload.gateRevision).toBe(6); ++ } ++ }), ++ ); ++ ++ /** ++ * Criterion 10, at the decider. Clear an approved gate, then deliver a write ++ * carrying a lower revision; the gate must not reappear. ++ */ ++ it.effect("CRITERION 10: a stale write after a clear does not resurrect the gate", () => ++ Effect.gen(function* () { ++ // The mark survived the clear, so the thread sits at 6 with no gate. ++ const afterClear = readModel([thread({ gateRevision: 6, codevGate: null })]); ++ const error = yield* refusal(setGate({ commandId: "cmd-stale", revision: 4 }), afterClear); ++ ++ expect(error.reason).toBe("CODEV_GATE_REVISION_STALE"); ++ expect(error.currentRevision).toBe(6); ++ // Had the mark lived inside the gate block it would have vanished with it, ++ // this write would have been the first at revision 4, and the answered gate ++ // would be back in front of a human. ++ }), ++ ); ++ ++ it.effect("refuses an EQUAL revision, not only a lower one", () => ++ Effect.gen(function* () { ++ const error = yield* refusal( ++ setGate({ commandId: "cmd-equal", revision: 6 }), ++ readModel([thread({ gateRevision: 6 })]), ++ ); ++ expect(error.reason).toBe("CODEV_GATE_REVISION_STALE"); ++ expect(error.currentRevision).toBe(6); ++ }), ++ ); ++ ++ it.effect("accepts a revision that genuinely exceeds the mark", () => ++ Effect.gen(function* () { ++ const events = yield* decide( ++ setGate({ commandId: "cmd-ahead", revision: 9 }), ++ readModel([thread({ gateRevision: 6 })]), ++ ); ++ if (events[0]?.type === "codev.gate-set") { ++ expect(events[0].payload.gateRevision).toBe(9); ++ } ++ }), ++ ); ++ ++ it.effect("a later gate on the same thread does not resurrect the first", () => ++ Effect.gen(function* () { ++ const first = yield* decide(setGate({ commandId: "cmd-a" }), readModel([thread({})])); ++ const firstRevision = ++ first[0]?.type === "codev.gate-set" ? first[0].payload.gateRevision : -1; ++ ++ const second = yield* decide( ++ setGate({ commandId: "cmd-b" }), ++ readModel([thread({ gateRevision: firstRevision, codevGate: GATE })]), ++ ); ++ const secondRevision = ++ second[0]?.type === "codev.gate-set" ? second[0].payload.gateRevision : -1; ++ ++ expect(secondRevision).toBeGreaterThan(firstRevision); ++ ++ // And the first one's revision is now stale, so a replay of it is refused. ++ const error = yield* refusal( ++ setGate({ commandId: "cmd-a-replay", revision: firstRevision }), ++ readModel([thread({ gateRevision: secondRevision })]), ++ ); ++ expect(error.reason).toBe("CODEV_GATE_REVISION_STALE"); ++ }), ++ ); ++ ++ /** ++ * "Two concurrent connections writing gates receive two different revisions." ++ * ++ * Commands are decided serially against the authoritative read model, so the ++ * second decision sees the first's mark. Modelled here as two decisions where ++ * the second's read model reflects the first — which is exactly what the engine ++ * queue produces. ++ */ ++ it.effect("two writers receive two different revisions", () => ++ Effect.gen(function* () { ++ const a = yield* decide(setGate({ commandId: "cmd-w1" }), readModel([thread({})])); ++ const aRevision = a[0]?.type === "codev.gate-set" ? a[0].payload.gateRevision : -1; ++ ++ const b = yield* decide( ++ setGate({ commandId: "cmd-w2" }), ++ readModel([thread({ gateRevision: aRevision })]), ++ ); ++ const bRevision = b[0]?.type === "codev.gate-set" ? b[0].payload.gateRevision : -1; ++ ++ expect(aRevision).not.toBe(bRevision); ++ expect(bRevision).toBeGreaterThan(aRevision); ++ }), ++ ); ++ ++ it.effect("a historical thread starts at revision 0 and its first write is 1", () => ++ Effect.gen(function* () { ++ // Rows predating the guard get 0 from the column default, and 0 must be a ++ // real starting point rather than an "unset" sentinel. ++ const events = yield* decide(setGate({ commandId: "cmd-hist" }), readModel([thread({ gateRevision: 0 })])); ++ if (events[0]?.type === "codev.gate-set") { ++ expect(events[0].payload.gateRevision).toBe(1); ++ } ++ }), ++ ); ++ ++ it.effect("refuses a gate write for a thread that does not exist", () => ++ Effect.gen(function* () { ++ const outcome = yield* Effect.result( ++ decide(setGate({ commandId: "cmd-missing" }), readModel([])), ++ ); ++ expect(outcome._tag).toBe("Failure"); ++ }), ++ ); ++}); +diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts +index 0ee8fd20e..fb5cf7b67 100644 +--- a/packages/contracts/src/orchestration.test.ts ++++ b/packages/contracts/src/orchestration.test.ts +@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; + import * as Schema from "effect/Schema"; + + import { ++ CodevGate, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + ClientOrchestrationCommand, +@@ -1176,3 +1177,131 @@ it.effect("decodes thread and shell with the hierarchy present and absent", () = + assert.strictEqual(explicitNull.parentThreadId, null); + }), + ); ++ ++// --------------------------------------------------------------------------- ++// Codev customization (spec 250) — the gate payload's bounds. ++// ++// The bounds are at the SCHEMA boundary, so an oversize or malformed gate is ++// refused whole. A gate that partially applied would leave a human looking at ++// half a question, and "half a question" is worse than no gate at all: it looks ++// answerable. ++ ++const decodeCodevGate = Schema.decodeUnknownEffect(CodevGate); ++ ++const validGate = { ++ gateName: "plan-approval", ++ requestedAt: "2026-01-01T00:00:00.000Z", ++ question: "Delete the legacy table, or keep it?", ++ choices: [ ++ { label: "Delete it", consequence: "Migrate references and drop it.", recommended: true }, ++ { label: "Keep it", consequence: "Retain it and document the dependency." }, ++ ], ++}; ++ ++const gateRefused = (gate: unknown) => ++ Effect.map(Effect.result(decodeCodevGate(gate)), (outcome) => outcome._tag === "Failure"); ++ ++it.effect("accepts a well-formed gate with a recommended choice", () => ++ Effect.gen(function* () { ++ const gate = yield* decodeCodevGate(validGate); ++ assert.strictEqual(gate.gateName, "plan-approval"); ++ assert.strictEqual(gate.choices?.length, 2); ++ }), ++); ++ ++it.effect("accepts a gate with no question and no choices", () => ++ Effect.gen(function* () { ++ // The structured request is optional; a bare gate name is still a gate, and ++ // porch's plain `porch gate` form produces exactly this. ++ const gate = yield* decodeCodevGate({ ++ gateName: "spec-approval", ++ requestedAt: "2026-01-01T00:00:00.000Z", ++ }); ++ assert.strictEqual(gate.gateName, "spec-approval"); ++ }), ++); ++ ++it.effect("refuses six choices", () => ++ Effect.gen(function* () { ++ const six = Array.from({ length: 6 }, (_, index) => ({ ++ label: `Option ${index}`, ++ consequence: "Something happens.", ++ })); ++ assert.strictEqual(yield* gateRefused({ ...validGate, choices: six }), true); ++ }), ++); ++ ++it.effect("refuses an empty choices array", () => ++ Effect.gen(function* () { ++ // Zero choices is not a question. An empty list would render as a prompt with ++ // nothing to pick, which reads as a broken UI rather than as a plain gate. ++ assert.strictEqual(yield* gateRefused({ ...validGate, choices: [] }), true); ++ }), ++); ++ ++it.effect("refuses two recommended choices", () => ++ Effect.gen(function* () { ++ // Two recommendations is no recommendation. ++ assert.strictEqual( ++ yield* gateRefused({ ++ ...validGate, ++ choices: [ ++ { label: "A", consequence: "a", recommended: true }, ++ { label: "B", consequence: "b", recommended: true }, ++ ], ++ }), ++ true, ++ ); ++ }), ++); ++ ++it.effect("refuses a multi-line question", () => ++ Effect.gen(function* () { ++ // It is rendered as a heading. A newline there is a layout bug that only ++ // shows up in front of a human who is already blocked. ++ assert.strictEqual( ++ yield* gateRefused({ ...validGate, question: "First line\nSecond line" }), ++ true, ++ ); ++ }), ++); ++ ++it.effect("refuses an empty gate name", () => ++ Effect.gen(function* () { ++ assert.strictEqual(yield* gateRefused({ ...validGate, gateName: " " }), true); ++ }), ++); ++ ++it.effect("refuses a choice with no consequence", () => ++ Effect.gen(function* () { ++ // A choice whose outcome is unstated asks a human to pick blind, which is the ++ // failure the structured request exists to remove. ++ assert.strictEqual( ++ yield* gateRefused({ ...validGate, choices: [{ label: "Do it" }] }), ++ true, ++ ); ++ }), ++); ++ ++it.effect("refuses a payload past the size cap", () => ++ Effect.gen(function* () { ++ assert.strictEqual( ++ yield* gateRefused({ ...validGate, question: "x".repeat(501) }), ++ true, ++ ); ++ assert.strictEqual( ++ yield* gateRefused({ ...validGate, terminalExcerpt: "y".repeat(8001) }), ++ true, ++ ); ++ }), ++); ++ ++it.effect("allows a multi-line terminal excerpt, which is rendered as a block", () => ++ Effect.gen(function* () { ++ const gate = yield* decodeCodevGate({ ++ ...validGate, ++ terminalExcerpt: "warning: legacy references remain\ncheckout tests failed", ++ }); ++ assert.ok(gate.terminalExcerpt?.includes("\n")); ++ }), ++); diff --git a/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch b/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch new file mode 100644 index 000000000..59fcac0d5 --- /dev/null +++ b/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch @@ -0,0 +1,266 @@ +From 6e8bdec207d6b1f531df581657b747ffc8f5c826 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 18:26:46 -0600 +Subject: [PATCH 08/34] [Spec 250][Phase: phase_4] test: hold up the two claims + the deviation rests on +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Architect's conditions on approving optional-on-the-wire gateRevision. +With the mark optional on the wire, "always a number" is carried entirely +by the database and by normalize-on-read, so both halves need tests that +can fail rather than sentences. + +1. The column REJECTS a NULL, asked of SQLite on a real row rather than by + grepping the DDL string — a DDL grep passes against a column SQLite + never actually constrained. PRAGMA is checked too, but PRAGMA is also + just a report. + + The first version of this ran the UPDATE against an empty table: zero + rows touched, trivially successful, and the assertion caught it only + because it expected a refusal. Written the other way round it would + have passed forever while proving nothing. The constraint needs + something to refuse. + +2. A record decoded with gateRevision ABSENT on the wire is a number and + not undefined, at both boundaries porch-driver crosses — the shell + snapshot and the full thread. Plus one that an explicit value survives + unchanged, because a default that fires when it should not is the same + bug as one that does not fire when it should. + +Verified to discriminate: with the decoding default removed, both wire +tests fail; restored, all pass. + +Also asserts a row inserted BEFORE the guard ran reads back as 0, so the +no-backfill claim rests on a row that really predates the column. + +Fork typecheck green. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/codev/schemaGuard.test.ts | 99 ++++++++++++++++++ + packages/contracts/src/orchestration.test.ts | 103 +++++++++++++++++++ + 2 files changed, 202 insertions(+) + +diff --git a/apps/server/src/codev/schemaGuard.test.ts b/apps/server/src/codev/schemaGuard.test.ts +index ffbbf2f72..3a11c4dbf 100644 +--- a/apps/server/src/codev/schemaGuard.test.ts ++++ b/apps/server/src/codev/schemaGuard.test.ts +@@ -270,6 +270,105 @@ describe("codev schema guard (spec 250)", () => { + ), + ); + ++ /** ++ * The architect's first condition on the optional-in/required-out deviation: ++ * assert the CONSTRAINT, not the DDL string. ++ * ++ * `gateRevision` is optional on the wire, so "the mark is always a number" is ++ * carried entirely by the database. A test that greps the `ALTER TABLE` text ++ * would pass against a column SQLite never actually constrained — it would be ++ * checking that we wrote the right sentence, not that the sentence took effect. ++ * So this asks SQLite: insert a NULL and require it to refuse. ++ */ ++ it.effect("the revision column REJECTS a null, enforced by SQLite and not by the DDL string", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* runMigrations(); ++ yield* applyCodevSchemaGuard(); ++ ++ const columns = yield* sql<{ ++ readonly name: string; ++ readonly notnull: number; ++ readonly dflt_value: string | null; ++ }>`PRAGMA table_info(projection_threads)`; ++ const revision = columns.find((column) => column.name === "codev_gate_revision"); ++ assert.ok(revision, "the revision column must exist"); ++ assert.strictEqual(revision.notnull, 1, "SQLite must report the column as NOT NULL"); ++ assert.strictEqual(revision.dflt_value, "0", "and DEFAULT 0, so historical rows are 0"); ++ ++ // And prove the constraint BITES, rather than trusting PRAGMA either. ++ // ++ // The row matters: an UPDATE against an empty table touches nothing and ++ // succeeds trivially, which is how the first version of this test passed ++ // while proving nothing. It has to have something to refuse. ++ yield* sql` ++ INSERT INTO projection_threads ( ++ thread_id, project_id, title, model_selection_json, runtime_mode, ++ interaction_mode, branch, worktree_path, latest_turn_id, ++ created_at, updated_at, archived_at, settled_override, settled_at, ++ snoozed_until, snoozed_at, pinned_at, latest_user_message_at, ++ pending_approval_count, pending_user_input_count, ++ has_actionable_proposed_plan, deleted_at ++ ) VALUES ( ++ 'thread-constraint', 'project-1', 'Constraint probe', ++ '{"instanceId":"codex","model":"gpt-5.4"}', ++ 'full-access', 'default', NULL, NULL, NULL, ++ '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, NULL, NULL, ++ NULL, NULL, NULL, NULL, 0, 0, 0, NULL ++ ) ++ `; ++ ++ const rejected = yield* Effect.result( ++ sql`UPDATE projection_threads SET codev_gate_revision = NULL WHERE thread_id = 'thread-constraint'`, ++ ); ++ assert.strictEqual( ++ rejected._tag, ++ "Failure", ++ "writing NULL into the revision column must be refused by the database", ++ ); ++ }), ++ ), ++ ); ++ ++ /** ++ * The default is what makes historical rows correct with no backfill, and a ++ * backfill is a second write that can be interrupted. Asserted on a row that ++ * really predates the column. ++ */ ++ it.effect("a row inserted before the guard ran reads back as revision 0, not null", () => ++ withDb( ++ Effect.gen(function* () { ++ const sql = yield* SqlClient.SqlClient; ++ yield* runMigrations(); ++ ++ yield* sql` ++ INSERT INTO projection_threads ( ++ thread_id, project_id, title, model_selection_json, runtime_mode, ++ interaction_mode, branch, worktree_path, latest_turn_id, ++ created_at, updated_at, archived_at, settled_override, settled_at, ++ snoozed_until, snoozed_at, pinned_at, latest_user_message_at, ++ pending_approval_count, pending_user_input_count, ++ has_actionable_proposed_plan, deleted_at ++ ) VALUES ( ++ 'thread-historical', 'project-1', 'Before the gate existed', ++ '{"instanceId":"codex","model":"gpt-5.4"}', ++ 'full-access', 'default', NULL, NULL, NULL, ++ '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, NULL, NULL, ++ NULL, NULL, NULL, NULL, 0, 0, 0, NULL ++ ) ++ `; ++ ++ yield* applyCodevSchemaGuard(); ++ ++ const rows = yield* sql<{ readonly codev_gate_revision: number | null }>` ++ SELECT codev_gate_revision FROM projection_threads WHERE thread_id = 'thread-historical' ++ `; ++ assert.strictEqual(rows[0]?.codev_gate_revision, 0, "the ALTER's DEFAULT filled it in place"); ++ }), ++ ), ++ ); ++ + it.effect("guards the table the rest of the phase reads and writes", () => + withDb( + Effect.gen(function* () { +diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts +index fb5cf7b67..4eb880562 100644 +--- a/packages/contracts/src/orchestration.test.ts ++++ b/packages/contracts/src/orchestration.test.ts +@@ -1305,3 +1305,106 @@ it.effect("allows a multi-line terminal excerpt, which is rendered as a block", + assert.ok(gate.terminalExcerpt?.includes("\n")); + }), + ); ++ ++// --------------------------------------------------------------------------- ++// Codev customization (spec 250) — "always a number after decoding". ++// ++// `gateRevision` is optional on the wire, so that sentence is a CLAIM, and this ++// project has taught us what claims are worth. These are the boundary ++// porch-driver actually crosses: a shell snapshot arriving from the server, and ++// a full thread record. If the field can come back `undefined` there, then ++// "no gate yet" has two spellings and every consumer needs a `?? 0` nobody will ++// remember to write. ++ ++it.effect("a shell decoded WITHOUT gateRevision on the wire is a number, not undefined", () => ++ Effect.gen(function* () { ++ const shell = yield* decodeOrchestrationThreadShell({ ++ id: "thread-wire", ++ projectId: "project-1", ++ title: "From an older server", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ runtimeMode: DEFAULT_RUNTIME_MODE, ++ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ++ branch: null, ++ worktreePath: null, ++ latestTurn: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ archivedAt: null, ++ session: null, ++ latestUserMessageAt: null, ++ hasPendingApprovals: false, ++ hasPendingUserInput: false, ++ hasActionableProposedPlan: false, ++ // gateRevision deliberately absent, exactly as a pre-fork server sends it. ++ }); ++ ++ assert.strictEqual(typeof shell.gateRevision, "number", "gateRevision must decode to a number"); ++ assert.strictEqual(shell.gateRevision, 0); ++ assert.notStrictEqual(shell.gateRevision, undefined); ++ }), ++); ++ ++it.effect("a thread decoded WITHOUT gateRevision on the wire is a number, not undefined", () => ++ Effect.gen(function* () { ++ const thread = yield* decodeOrchestrationThread({ ++ id: "thread-wire-2", ++ projectId: "project-1", ++ title: "From an older server", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ runtimeMode: DEFAULT_RUNTIME_MODE, ++ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ++ branch: null, ++ worktreePath: null, ++ latestTurn: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ archivedAt: null, ++ session: null, ++ deletedAt: null, ++ messages: [], ++ proposedPlans: [], ++ activities: [], ++ checkpoints: [], ++ }); ++ ++ assert.strictEqual(typeof thread.gateRevision, "number"); ++ assert.strictEqual(thread.gateRevision, 0); ++ // And no gate, which is the other half of "no gate yet" having one spelling. ++ assert.ok(thread.codevGate === null || thread.codevGate === undefined); ++ }), ++); ++ ++it.effect("an explicit gateRevision on the wire survives decoding unchanged", () => ++ Effect.gen(function* () { ++ // The default must not overwrite a real value — a default that fires when it ++ // should not is the same bug as one that does not fire when it should. ++ const shell = yield* decodeOrchestrationThreadShell({ ++ id: "thread-wire-3", ++ projectId: "project-1", ++ title: "Mid-gate", ++ modelSelection: { provider: "codex", model: "gpt-5.4" }, ++ runtimeMode: DEFAULT_RUNTIME_MODE, ++ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, ++ branch: null, ++ worktreePath: null, ++ latestTurn: null, ++ createdAt: "2026-01-01T00:00:00.000Z", ++ updatedAt: "2026-01-01T00:00:00.000Z", ++ archivedAt: null, ++ session: null, ++ latestUserMessageAt: null, ++ hasPendingApprovals: false, ++ hasPendingUserInput: false, ++ hasActionableProposedPlan: false, ++ gateRevision: 7, ++ codevGate: { ++ gateName: "plan-approval", ++ requestedAt: "2026-01-01T00:00:00.000Z", ++ }, ++ }); ++ ++ assert.strictEqual(shell.gateRevision, 7); ++ assert.strictEqual(shell.codevGate?.gateName, "plan-approval"); ++ }), ++); diff --git a/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch b/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch new file mode 100644 index 000000000..995925a1e --- /dev/null +++ b/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch @@ -0,0 +1,934 @@ +From 3d0e76776cd9fa76947099c2f2c4635ae8c047ed Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 18:44:30 -0600 +Subject: [PATCH 09/34] [Spec 250][Phase: phase_4] fix: the engine was deleting + gate refusals too +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Both lanes found it and it is the phase 3 bug, in the same function, one +phase later. `isRefusal` listed two refusal types; phase 4 added a third +and did not extend it, so every gate refusal was rewritten as "Failed to +generate an event identifier" and CRITERION 10 WAS FALSE AT THE WIRE while +all 11 decider tests stayed green. Adding a refusal type without adding it +there is now the same mistake three times. + +An engine-level test covers it, verified to discriminate: with isRefusal +reverted the stale-write test fails; restored, it passes. + +Reason taxonomy, from the same review. "Could not tell" was sharing a +spelling with "no": + + CODEV_GATE_WRITE_UNCONFIRMED new. The write may have landed and we + cannot name its revision. Not a rare defensive path — an idempotent + replay of the same commandId commits nothing, returns no events, and + lands here EVERY time. A normal retry was being reported as a + nonexistent thread. + CODEV_GATE_WRITE_FAILED new. Database and decode failures, which + were being relabelled as a missing thread — hiding a broken database + behind ordinary caller error. + CODEV_GATE_SCOPE_REQUIRED dropped. It was declared and never + constructed; the real refusal is EnvironmentAuthorizationError + carrying requiredScope "codev:gate-write", which is already distinct + from a 401 and from another scope failure. A second path the transport + already blocks would be unreachable code. + +CODEV_GATE_THREAD_NOT_FOUND was also declared and never constructed: a +missing thread raised the generic invariant error, making the RPC's +declared error type a lie for its commonest failure. The decider now +raises the declared error. Found by tightening a test, not by reading. + +Two of my own tests asserted nothing, again. Assertions sat inside +`if (events[0]?.type === "codev.gate-set")`, which passes vacuously when +the type is wrong, and the thread-not-found test asserted only that +something failed. Replaced with helpers that assert the type first and +return the payload. + +THE FINDING THAT MATTERS MOST: nothing asserted the projector applies +either gate event. Every decider test hand-builds its read model, so a +projector that dropped gateRevision would pass all 11 while every write +after the first re-allocated revision 1 — precisely the failure the +revision mechanism exists to prevent, invisible to the suite meant to +protect it. Six projector tests now cover it, verified to fail when the +mark is dropped. + +Also: the OAuth token allowlist exclusion is now asserted (the third place +the scope must not appear, and the easiest to add by reflex), and +codev/gateCredential.ts names the issuance API and the on-disk path of the +single credential — scopes asserted as a set, token written 0600 via a +temp-and-rename so a reader never sees a half-written token. + +Fork typecheck green; contracts 304 passed, server 2832 passed. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/auth/CodevGateScope.test.ts | 31 +++++ + apps/server/src/codev/gateCredential.test.ts | 98 ++++++++++++++ + apps/server/src/codev/gateCredential.ts | 92 +++++++++++++ + apps/server/src/orchestration/Errors.ts | 5 + + ...OrchestrationEngine.codevHierarchy.test.ts | 79 ++++++++++++ + .../Layers/OrchestrationEngine.ts | 22 +++- + .../orchestration/decider.codevGate.test.ts | 122 ++++++++++-------- + apps/server/src/orchestration/decider.ts | 19 ++- + .../projector.codevHierarchy.test.ts | 118 +++++++++++++++++ + apps/server/src/ws.ts | 32 +++-- + packages/contracts/src/orchestration.ts | 21 ++- + 11 files changed, 562 insertions(+), 77 deletions(-) + create mode 100644 apps/server/src/codev/gateCredential.test.ts + create mode 100644 apps/server/src/codev/gateCredential.ts + +diff --git a/apps/server/src/auth/CodevGateScope.test.ts b/apps/server/src/auth/CodevGateScope.test.ts +index 4f2c7ab38..62fea3d7e 100644 +--- a/apps/server/src/auth/CodevGateScope.test.ts ++++ b/apps/server/src/auth/CodevGateScope.test.ts +@@ -72,6 +72,37 @@ describe("codev:gate-write scope (spec 250)", () => { + }); + }); + ++describe("codev:gate-write is not obtainable through the OAuth token route (spec 250)", () => { ++ /** ++ * The third place the scope must not appear, and the one that is easiest to ++ * add by reflex: the token-issuance allowlist in `auth/http.ts`. ++ * ++ * `AuthStandardClientScopes` governs what a client is issued by default; THIS ++ * list governs what a client may ASK for. A scope excluded from the first and ++ * present in the second is not excluded at all — anyone can request it. Nothing ++ * asserted this until review pointed it out. ++ * ++ * Asserted against the source, because the list is an inline literal inside a ++ * handler with no exported value to inspect. That is itself worth noting: an ++ * allowlist nobody can read from a test is an allowlist nobody can check. ++ */ ++ it("is absent from the OAuth scope allowlist", async () => { ++ const { readFileSync } = await import("node:fs"); ++ const { fileURLToPath } = await import("node:url"); ++ const source = readFileSync(fileURLToPath(new URL("./http.ts", import.meta.url)), "utf8"); ++ ++ const start = source.indexOf("allowedScopes: new Set(["); ++ expect(start, "the OAuth scope allowlist must still exist").toBeGreaterThan(-1); ++ const allowlist = source.slice(start, source.indexOf("]),", start)); ++ ++ expect(allowlist).not.toContain("AuthCodevGateWriteScope"); ++ expect(allowlist).not.toContain("codev:gate-write"); ++ // And it does still allow the ordinary ones, so this is not passing because ++ // the list moved or emptied. ++ expect(allowlist).toContain("AuthOrchestrationOperateScope"); ++ }); ++}); ++ + describe("codev gate commands stay off the dispatchCommand payload (spec 250)", () => { + /** + * Asserted against the contract SOURCE, because the property is an absence and +diff --git a/apps/server/src/codev/gateCredential.test.ts b/apps/server/src/codev/gateCredential.test.ts +new file mode 100644 +index 000000000..d588a7f27 +--- /dev/null ++++ b/apps/server/src/codev/gateCredential.test.ts +@@ -0,0 +1,98 @@ ++/** ++ * Codev customization (spec 250) — the single `codev:gate-write` credential. ++ * ++ * The scope's whole job is to exclude callers, so the credential holding it is ++ * the security boundary. These assert the two things that would silently widen ++ * it: the scope set, and the file mode. ++ */ ++ ++import { ++ AuthCodevGateWriteScope, ++ AuthOrchestrationOperateScope, ++ AuthOrchestrationReadScope, ++ AuthStandardClientScopes, ++} from "@t3tools/contracts"; ++import * as NodeServices from "@effect/platform-node/NodeServices"; ++import { assert, describe, it } from "@effect/vitest"; ++import * as Effect from "effect/Effect"; ++import * as FileSystem from "effect/FileSystem"; ++import * as Path from "effect/Path"; ++ ++import { ++ CODEV_GATE_WRITER_LABEL, ++ CODEV_GATE_WRITER_SCOPES, ++ CODEV_GATE_WRITER_SUBJECT, ++ codevGateWriterTokenPath, ++ writeCodevGateWriterToken, ++} from "./gateCredential.ts"; ++ ++describe("codev gate-writer credential (spec 250)", () => { ++ it("holds exactly read plus gate-write", () => { ++ // Asserted as a SET, not a containment check. A containment check passes ++ // while the credential quietly grows, which is the failure this whole ++ // arrangement exists to prevent and which would not look wrong at the call ++ // site. ++ assert.deepStrictEqual( ++ [...CODEV_GATE_WRITER_SCOPES].sort(), ++ [AuthCodevGateWriteScope, AuthOrchestrationReadScope].sort(), ++ ); ++ }); ++ ++ it("does NOT hold orchestration:operate", () => { ++ // codev-agent publishes gate state; it does not create threads or drive ++ // turns. Granting operate would make this credential a superset of an ++ // ordinary client's and remove the point of separating them. ++ assert.ok(!CODEV_GATE_WRITER_SCOPES.includes(AuthOrchestrationOperateScope)); ++ }); ++ ++ it("holds a scope no ordinary client is issued", () => { ++ // The two facts together are the boundary: this credential has something the ++ // standard set does not, and the standard set is what a paired thread gets. ++ assert.ok(CODEV_GATE_WRITER_SCOPES.includes(AuthCodevGateWriteScope)); ++ const standard: ReadonlyArray = AuthStandardClientScopes; ++ assert.ok(!standard.includes(AuthCodevGateWriteScope)); ++ }); ++ ++ it("is identifiable in a session listing", () => { ++ assert.strictEqual(CODEV_GATE_WRITER_SUBJECT, "codev-agent"); ++ assert.ok(CODEV_GATE_WRITER_LABEL.includes("gate writer")); ++ }); ++ ++ it.effect("writes the token 0600 and readable, under the server's base dir", () => ++ Effect.gen(function* () { ++ const fs = yield* FileSystem.FileSystem; ++ const path = yield* Path.Path; ++ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "codev-gate-cred-" }); ++ ++ const target = yield* writeCodevGateWriterToken({ baseDir, token: "tok_example" }); ++ ++ assert.strictEqual(target, codevGateWriterTokenPath(baseDir, path)); ++ assert.strictEqual((yield* fs.readFileString(target)).trim(), "tok_example"); ++ ++ // The mode is the point. A directory anyone can traverse still hands out a ++ // world-readable file inside it, so the mode is set on the file itself. ++ const info = yield* fs.stat(target); ++ const mode = Number(info.mode) & 0o777; ++ assert.strictEqual(mode, 0o600, `expected 0600, got 0${mode.toString(8)}`); ++ ++ // And nothing is left behind that a reader could pick up mid-write: a ++ // truncated bearer token fails auth in a way that looks like revocation. ++ assert.strictEqual(yield* fs.exists(`${target}.partial`), false); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ++ ); ++ ++ it.effect("overwrites an existing token rather than appending to it", () => ++ Effect.gen(function* () { ++ const fs = yield* FileSystem.FileSystem; ++ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "codev-gate-cred-2-" }); ++ ++ yield* writeCodevGateWriterToken({ baseDir, token: "first" }); ++ const target = yield* writeCodevGateWriterToken({ baseDir, token: "second" }); ++ ++ // Two tokens in one file is one unusable file, and it would authenticate as ++ // neither — reported to an operator as a credential problem rather than as ++ // the write bug it is. ++ assert.strictEqual((yield* fs.readFileString(target)).trim(), "second"); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ++ ); ++}); +diff --git a/apps/server/src/codev/gateCredential.ts b/apps/server/src/codev/gateCredential.ts +new file mode 100644 +index 000000000..04fdc7632 +--- /dev/null ++++ b/apps/server/src/codev/gateCredential.ts +@@ -0,0 +1,92 @@ ++/** ++ * Codev customization (spec 250) — the single `codev:gate-write` credential. ++ * ++ * The scope exists to exclude callers, so the credential that holds it is the ++ * whole security boundary. Leaving its issuance and location "to the implementer" ++ * is how a scope like this ends up quietly attached to something broader; this ++ * file names both. ++ * ++ * ISSUANCE API: `EnvironmentAuth.issueSession({ subject, scopes, label })` — ++ * upstream's own bearer-session issuance, not a new mechanism. It already accepts ++ * an explicit scope list, so the customization is *which* scopes, not *how*. ++ * ++ * ON-DISK PATH: `/codev/gate-writer.token`, mode `0600`. ++ * `baseDir` is the server's own data directory (`--base-dir`), so the credential ++ * lives with the database it can write to and moves with it. `codev/` rather than ++ * the root so it is obviously ours. ++ * ++ * WHAT IT HOLDS, and what it deliberately does not: ++ * ++ * orchestration:read it must know which thread a gate belongs to. ++ * codev:gate-write its reason for existing. ++ * ++ * NOT `orchestration:operate`. `codev-agent` publishes gate state; it does not ++ * create threads or drive turns — `porch-driver` does, with an ordinary client ++ * credential. Granting operate here would make this credential a superset of an ++ * ordinary client's and remove the point of separating them. ++ * ++ * NEVER ISSUED TO A THREAD. Threads receive client sessions through the pairing ++ * flow, which draws from `AuthStandardClientScopes` — a list this scope is ++ * deliberately absent from. There is no code path from a thread to this token; ++ * the only way to hold it is to be able to read this file. ++ */ ++ ++import * as Effect from "effect/Effect"; ++import * as FileSystem from "effect/FileSystem"; ++import * as Path from "effect/Path"; ++import { ++ AuthCodevGateWriteScope, ++ AuthOrchestrationReadScope, ++ type AuthEnvironmentScope, ++} from "@t3tools/contracts"; ++ ++/** The one credential's subject, so it is identifiable in a session listing. */ ++export const CODEV_GATE_WRITER_SUBJECT = "codev-agent"; ++ ++/** Human-readable label, shown wherever sessions are listed. */ ++export const CODEV_GATE_WRITER_LABEL = "codev-agent (gate writer)"; ++ ++/** ++ * Exactly the scopes this credential holds. ++ * ++ * Frozen and exported so a test can assert the set rather than trusting the call ++ * site — a scope added here by reflex is the failure this whole arrangement ++ * exists to prevent, and it would not look wrong at the call site. ++ */ ++export const CODEV_GATE_WRITER_SCOPES: ReadonlyArray = [ ++ AuthOrchestrationReadScope, ++ AuthCodevGateWriteScope, ++]; ++ ++/** Path of the token file, relative to the server's base directory. */ ++export const CODEV_GATE_WRITER_TOKEN_RELATIVE_PATH = ["codev", "gate-writer.token"] as const; ++ ++export const codevGateWriterTokenPath = (baseDir: string, path: Path.Path): string => ++ path.join(baseDir, ...CODEV_GATE_WRITER_TOKEN_RELATIVE_PATH); ++ ++/** ++ * Write the token where `codev-agent` reads it, at `0600`. ++ * ++ * The mode is set on the file, not on the directory alone: a directory anyone can ++ * traverse still hands out a world-readable file inside it. Written whole and ++ * then moved into place, so a reader never sees a half-written token — a ++ * truncated bearer token fails authentication in a way that looks like ++ * revocation. ++ */ ++export const writeCodevGateWriterToken = Effect.fn("writeCodevGateWriterToken")(function* (input: { ++ readonly baseDir: string; ++ readonly token: string; ++}) { ++ const fs = yield* FileSystem.FileSystem; ++ const path = yield* Path.Path; ++ ++ const target = codevGateWriterTokenPath(input.baseDir, path); ++ yield* fs.makeDirectory(path.dirname(target), { recursive: true }); ++ ++ const temporary = `${target}.partial`; ++ yield* fs.writeFileString(temporary, `${input.token}\n`); ++ yield* fs.chmod(temporary, 0o600); ++ yield* fs.rename(temporary, target); ++ ++ return target; ++}); +diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts +index 36ae8fd5a..5b1eb51b1 100644 +--- a/apps/server/src/orchestration/Errors.ts ++++ b/apps/server/src/orchestration/Errors.ts +@@ -1,5 +1,6 @@ + import * as SchemaIssue from "effect/SchemaIssue"; + import * as Schema from "effect/Schema"; ++import type { CodevGateWriteError } from "@t3tools/contracts"; + + import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; + +@@ -142,6 +143,10 @@ export type OrchestrationDispatchError = + | ProjectionRepositoryError + | OrchestrationCommandInvariantError + | CodevHierarchyInvalidError ++ // Codev customization (spec 250). A gate refusal is a REFUSAL and must reach ++ // the dispatcher intact — see `isRefusal` in OrchestrationEngine.ts. Omitting ++ // it here is how phase 4 reintroduced phase 3's bug in the same function. ++ | CodevGateWriteError + | OrchestrationCommandIdConflictError + | OrchestrationCommandPreviouslyRejectedError + | OrchestrationProjectorDecodeError +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +index 0d04eb6f0..ba7084b55 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +@@ -38,6 +38,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; + import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; + import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; + import { CodevHierarchyInvalidError } from "../Errors.ts"; ++import { CodevGateWriteError } from "@t3tools/contracts"; + import { ServerConfig } from "../../config.ts"; + + const NOW = "2026-01-01T00:00:00.000Z"; +@@ -250,6 +251,84 @@ describe("OrchestrationEngine: codev hierarchy refusals (spec 250)", () => { + } + }); + ++ /** ++ * Codev customization (spec 250). Criterion 10 AT THE WIRE. ++ * ++ * The decider tests prove the revision rules; they cannot prove the refusal ++ * survives `OrchestrationEngine`. It did not: `isRefusal` listed two refusal ++ * types and phase 4 added a third without extending it, so every gate refusal ++ * was rewritten as "Failed to generate an event identifier" while all eleven ++ * decider tests stayed green. That is phase 3's bug, in the same function, one ++ * phase later — which is why this assertion now lives beside the hierarchy one. ++ */ ++ it("a stale gate write is refused at the wire with its own reason", async () => { ++ const system = await seededSystem(); ++ try { ++ const gate = { ++ gateName: "plan-approval", ++ requestedAt: NOW, ++ }; ++ const write = (commandId: string, revision?: number) => ++ ({ ++ type: "codev.gate.set", ++ commandId: CommandId.make(commandId), ++ threadId: ThreadId.make("thread-architect"), ++ gate, ++ ...(revision === undefined ? {} : { revision }), ++ createdAt: NOW, ++ }) as never; ++ ++ // Allocate a real mark first, so the stale write below is genuinely stale. ++ await system.run(system.engine.dispatch(write("cmd-gate-1"))); ++ ++ const outcome = await system.run( ++ Effect.result(system.engine.dispatch(write("cmd-gate-stale", 1))), ++ ); ++ expect(outcome._tag).toBe("Failure"); ++ const error = (outcome as { failure: unknown }).failure; ++ ++ expect( ++ error instanceof CodevGateWriteError, ++ `the engine collapsed the gate refusal into ${String(error)}`, ++ ).toBe(true); ++ expect((error as CodevGateWriteError).reason).toBe("CODEV_GATE_REVISION_STALE"); ++ expect(String((error as CodevGateWriteError).message)).not.toContain( ++ "Failed to generate an event identifier", ++ ); ++ } finally { ++ await system.dispose(); ++ } ++ }); ++ ++ it("a gate write returns the revision the server allocated for it", async () => { ++ const system = await seededSystem(); ++ try { ++ const write = (commandId: string) => ++ ({ ++ type: "codev.gate.set", ++ commandId: CommandId.make(commandId), ++ threadId: ThreadId.make("thread-architect"), ++ gate: { gateName: "plan-approval", requestedAt: NOW }, ++ createdAt: NOW, ++ }) as never; ++ ++ const first = await system.run(system.engine.dispatch(write("cmd-rev-1"))); ++ const second = await system.run(system.engine.dispatch(write("cmd-rev-2"))); ++ ++ const revisionOf = (result: { readonly events: ReadonlyArray<{ type: string }> }) => { ++ const event = result.events.find((e) => e.type === "codev.gate-set"); ++ expect(event, "the dispatch must hand back the event it committed").toBeDefined(); ++ return (event as unknown as { payload: { gateRevision: number } }).payload.gateRevision; ++ }; ++ ++ // Two writes, two different numbers, both allocated by the server and both ++ // readable by the caller that made them. ++ expect(revisionOf(second)).toBeGreaterThan(revisionOf(first)); ++ } finally { ++ await system.dispose(); ++ } ++ }); ++ + it("accepts the legal architect -> builder edge through the engine", async () => { + const system = await seededSystem(); + try { +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +index 068c7fbb9..ac5266f18 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +@@ -5,7 +5,7 @@ import type { + ProjectId, + ThreadId, + } from "@t3tools/contracts"; +-import { OrchestrationCommand } from "@t3tools/contracts"; ++import { CodevGateWriteError, OrchestrationCommand } from "@t3tools/contracts"; + import * as Cause from "effect/Cause"; + import * as Clock from "effect/Clock"; + import * as Crypto from "effect/Crypto"; +@@ -67,11 +67,25 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar + * decider tests stayed green, because they call the decider directly. + */ + const isCodevHierarchyInvalidError = Schema.is(CodevHierarchyInvalidError); +-/** Errors that describe a REFUSAL and must reach the dispatcher intact. */ ++const isCodevGateWriteError = Schema.is(CodevGateWriteError); ++ ++/** ++ * Errors that describe a REFUSAL and must reach the dispatcher intact. ++ * ++ * EVERY refusal type has to be listed here. Phase 3 fixed this function once, ++ * for `CodevHierarchyInvalidError`; phase 4 added a third refusal type and did ++ * not extend it, so gate refusals were rewritten as "Failed to generate an event ++ * identifier" and criterion 10 was false at the wire while all 11 decider tests ++ * stayed green. Both review lanes caught it. Adding a refusal type without adding ++ * it here is the same bug a third time. ++ */ + const isRefusal = (cause: unknown): cause is + | OrchestrationCommandInvariantError +- | CodevHierarchyInvalidError => +- isOrchestrationCommandInvariantError(cause) || isCodevHierarchyInvalidError(cause); ++ | CodevHierarchyInvalidError ++ | CodevGateWriteError => ++ isOrchestrationCommandInvariantError(cause) || ++ isCodevHierarchyInvalidError(cause) || ++ isCodevGateWriteError(cause); + + interface CommandEnvelope { + command: OrchestrationCommand; +diff --git a/apps/server/src/orchestration/decider.codevGate.test.ts b/apps/server/src/orchestration/decider.codevGate.test.ts +index f4ab3b673..21abb75ce 100644 +--- a/apps/server/src/orchestration/decider.codevGate.test.ts ++++ b/apps/server/src/orchestration/decider.codevGate.test.ts +@@ -102,6 +102,28 @@ const decide = (command: unknown, model: OrchestrationReadModel) => + (event) => (Array.isArray(event) ? event : [event]) as ReadonlyArray, + ); + ++/** ++ * Assert the event type and RETURN the payload. ++ * ++ * Every assertion here used to sit inside `if (events[0]?.type === "codev.gate-set")`, ++ * which passes vacuously when the type is wrong — the test reports success by ++ * asserting nothing. Review caught it. Now the type is asserted first, so a wrong ++ * event fails here rather than silently skipping the checks that follow. ++ */ ++function gateSetPayload(events: ReadonlyArray) { ++ assert.strictEqual(events.length, 1, "a gate write emits exactly one event"); ++ const event = events[0]; ++ assert.ok(event !== undefined && event.type === "codev.gate-set", `expected codev.gate-set, got ${events[0]?.type}`); ++ return (event as Extract).payload; ++} ++ ++function gateClearedPayload(events: ReadonlyArray) { ++ assert.strictEqual(events.length, 1, "a gate clear emits exactly one event"); ++ const event = events[0]; ++ assert.ok(event !== undefined && event.type === "codev.gate-cleared", `expected codev.gate-cleared, got ${events[0]?.type}`); ++ return (event as Extract).payload; ++} ++ + const refusal = (command: unknown, model: OrchestrationReadModel) => + Effect.gen(function* () { + const outcome = yield* Effect.result(decide(command, model)); +@@ -114,25 +136,21 @@ const refusal = (command: unknown, model: OrchestrationReadModel) => + it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { + it.effect("allocates the next revision when the command carries none", () => + Effect.gen(function* () { +- const events = yield* decide(setGate({ commandId: "cmd-1" }), readModel([thread({})])); +- expect(events[0]?.type).toBe("codev.gate-set"); +- if (events[0]?.type === "codev.gate-set") { +- // Allocated, not echoed: the command carried no revision at all. +- expect(events[0].payload.gateRevision).toBe(1); +- expect(events[0].payload.gate.gateName).toBe("plan-approval"); +- } ++ const payload = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-1" }), readModel([thread({})])), ++ ); ++ // Allocated, not echoed: the command carried no revision at all. ++ expect(payload.gateRevision).toBe(1); ++ expect(payload.gate.gateName).toBe("plan-approval"); + }), + ); + + it.effect("allocates from the CURRENT mark, not from zero", () => + Effect.gen(function* () { +- const events = yield* decide( +- setGate({ commandId: "cmd-2" }), +- readModel([thread({ gateRevision: 41 })]), ++ const payload = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-2" }), readModel([thread({ gateRevision: 41 })])), + ); +- if (events[0]?.type === "codev.gate-set") { +- expect(events[0].payload.gateRevision).toBe(42); +- } ++ expect(payload.gateRevision).toBe(42); + }), + ); + +@@ -143,26 +161,22 @@ it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { + */ + it.effect("a writer that forgot everything still gets a higher number", () => + Effect.gen(function* () { +- const events = yield* decide( +- setGate({ commandId: "cmd-restart" }), +- readModel([thread({ gateRevision: 9 })]), ++ const payload = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-restart" }), readModel([thread({ gateRevision: 9 })])), + ); +- if (events[0]?.type === "codev.gate-set") { +- expect(events[0].payload.gateRevision).toBe(10); +- } ++ expect(payload.gateRevision).toBe(10); + }), + ); + + it.effect("clearing raises the mark rather than resetting it", () => + Effect.gen(function* () { +- const events = yield* decide( +- clearGate({ commandId: "cmd-clear" }), +- readModel([thread({ gateRevision: 5, codevGate: GATE })]), ++ const payload = gateClearedPayload( ++ yield* decide( ++ clearGate({ commandId: "cmd-clear" }), ++ readModel([thread({ gateRevision: 5, codevGate: GATE })]), ++ ), + ); +- expect(events[0]?.type).toBe("codev.gate-cleared"); +- if (events[0]?.type === "codev.gate-cleared") { +- expect(events[0].payload.gateRevision).toBe(6); +- } ++ expect(payload.gateRevision).toBe(6); + }), + ); + +@@ -197,28 +211,25 @@ it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { + + it.effect("accepts a revision that genuinely exceeds the mark", () => + Effect.gen(function* () { +- const events = yield* decide( +- setGate({ commandId: "cmd-ahead", revision: 9 }), +- readModel([thread({ gateRevision: 6 })]), ++ const payload = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-ahead", revision: 9 }), readModel([thread({ gateRevision: 6 })])), + ); +- if (events[0]?.type === "codev.gate-set") { +- expect(events[0].payload.gateRevision).toBe(9); +- } ++ expect(payload.gateRevision).toBe(9); + }), + ); + + it.effect("a later gate on the same thread does not resurrect the first", () => + Effect.gen(function* () { +- const first = yield* decide(setGate({ commandId: "cmd-a" }), readModel([thread({})])); +- const firstRevision = +- first[0]?.type === "codev.gate-set" ? first[0].payload.gateRevision : -1; ++ const firstRevision = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-a" }), readModel([thread({})])), ++ ).gateRevision; + +- const second = yield* decide( +- setGate({ commandId: "cmd-b" }), +- readModel([thread({ gateRevision: firstRevision, codevGate: GATE })]), +- ); +- const secondRevision = +- second[0]?.type === "codev.gate-set" ? second[0].payload.gateRevision : -1; ++ const secondRevision = gateSetPayload( ++ yield* decide( ++ setGate({ commandId: "cmd-b" }), ++ readModel([thread({ gateRevision: firstRevision, codevGate: GATE })]), ++ ), ++ ).gateRevision; + + expect(secondRevision).toBeGreaterThan(firstRevision); + +@@ -241,14 +252,13 @@ it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { + */ + it.effect("two writers receive two different revisions", () => + Effect.gen(function* () { +- const a = yield* decide(setGate({ commandId: "cmd-w1" }), readModel([thread({})])); +- const aRevision = a[0]?.type === "codev.gate-set" ? a[0].payload.gateRevision : -1; ++ const aRevision = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-w1" }), readModel([thread({})])), ++ ).gateRevision; + +- const b = yield* decide( +- setGate({ commandId: "cmd-w2" }), +- readModel([thread({ gateRevision: aRevision })]), +- ); +- const bRevision = b[0]?.type === "codev.gate-set" ? b[0].payload.gateRevision : -1; ++ const bRevision = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-w2" }), readModel([thread({ gateRevision: aRevision })])), ++ ).gateRevision; + + expect(aRevision).not.toBe(bRevision); + expect(bRevision).toBeGreaterThan(aRevision); +@@ -259,19 +269,19 @@ it.layer(NodeServices.layer)("codev gate revision (spec 250)", (it) => { + Effect.gen(function* () { + // Rows predating the guard get 0 from the column default, and 0 must be a + // real starting point rather than an "unset" sentinel. +- const events = yield* decide(setGate({ commandId: "cmd-hist" }), readModel([thread({ gateRevision: 0 })])); +- if (events[0]?.type === "codev.gate-set") { +- expect(events[0].payload.gateRevision).toBe(1); +- } ++ const payload = gateSetPayload( ++ yield* decide(setGate({ commandId: "cmd-hist" }), readModel([thread({ gateRevision: 0 })])), ++ ); ++ expect(payload.gateRevision).toBe(1); + }), + ); + + it.effect("refuses a gate write for a thread that does not exist", () => + Effect.gen(function* () { +- const outcome = yield* Effect.result( +- decide(setGate({ commandId: "cmd-missing" }), readModel([])), +- ); +- expect(outcome._tag).toBe("Failure"); ++ // Asserting only `_tag === "Failure"` passes on ANY failure, including one ++ // raised for a completely different reason. Review caught that too. ++ const error = yield* refusal(setGate({ commandId: "cmd-missing" }), readModel([])); ++ expect(error.reason).toBe("CODEV_GATE_THREAD_NOT_FOUND"); + }), + ); + }); +diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts +index cd312775e..47adff1bf 100644 +--- a/apps/server/src/orchestration/decider.ts ++++ b/apps/server/src/orchestration/decider.ts +@@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; + + import { CodevHierarchyInvalidError, OrchestrationCommandInvariantError } from "./Errors.ts"; + import { ++ findThreadById, + listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, + requireCodevHierarchy, +@@ -428,11 +429,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" + */ + case "codev.gate.set": + case "codev.gate.clear": { +- const thread = yield* requireThread({ +- readModel, +- command, +- threadId: command.threadId, +- }); ++ // Not `requireThread`: that raises the generic invariant error, and the ++ // gate RPC declares its error as `CodevGateWriteError`. Raising the generic ++ // one made the RPC's declared error type a lie for the commonest failure, ++ // and left CODEV_GATE_THREAD_NOT_FOUND declared but never constructed — ++ // the same defect review flagged for the scope reason. A stricter test ++ // found it. ++ const thread = findThreadById(readModel, command.threadId); ++ if (thread === undefined) { ++ return yield* new CodevGateWriteError({ ++ reason: "CODEV_GATE_THREAD_NOT_FOUND", ++ message: `Gate write refused: thread '${command.threadId}' does not exist.`, ++ }); ++ } + const currentRevision = thread.gateRevision ?? 0; + + if (command.revision !== undefined && command.revision <= currentRevision) { +diff --git a/apps/server/src/orchestration/projector.codevHierarchy.test.ts b/apps/server/src/orchestration/projector.codevHierarchy.test.ts +index 2873e8f22..bb0470afe 100644 +--- a/apps/server/src/orchestration/projector.codevHierarchy.test.ts ++++ b/apps/server/src/orchestration/projector.codevHierarchy.test.ts +@@ -122,6 +122,124 @@ async function rebuild(events: ReadonlyArray) { + return model; + } + ++/** ++ * Codev customization (spec 250) — the gate through the PROJECTOR. ++ * ++ * Review's finding, and it is the sharpest one in phase 4: nothing asserted that ++ * the read model applies either gate event. Every decider test hand-builds its ++ * read model, so a projector that dropped `gateRevision` would pass all eleven of ++ * them while every write after the first re-allocated revision 1 — which is ++ * precisely the failure the revision mechanism exists to prevent, invisible to ++ * the suite that was meant to protect it. ++ * ++ * These read the mark back out of a projected model, so a projector that forgets ++ * it fails here. ++ */ ++describe("projector: codev gate (spec 250)", () => { ++ const gate = { ++ gateName: "plan-approval", ++ requestedAt: NOW, ++ question: "Ship it?", ++ choices: [{ label: "Ship", consequence: "It ships." }], ++ }; ++ ++ const gateSet = (sequence: number, gateRevision: number) => ++ makeEvent({ ++ sequence, ++ type: "codev.gate-set", ++ aggregateKind: "thread", ++ aggregateId: "thread-a", ++ payload: { threadId: "thread-a", gate, gateRevision, updatedAt: NOW }, ++ }); ++ ++ const gateCleared = (sequence: number, gateRevision: number) => ++ makeEvent({ ++ sequence, ++ type: "codev.gate-cleared", ++ aggregateKind: "thread", ++ aggregateId: "thread-a", ++ payload: { threadId: "thread-a", gateRevision, updatedAt: NOW }, ++ }); ++ ++ it("applies a gate and its revision to the read model", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ gateSet(3, 1), ++ ]); ++ const thread = model.threads.find((t) => t.id === "thread-a"); ++ expect(thread?.codevGate?.gateName).toBe("plan-approval"); ++ expect(thread?.gateRevision).toBe(1); ++ }); ++ ++ it("a new thread starts with no gate at revision 0", async () => { ++ const model = await rebuild([projectCreated, preForkThreadCreated(2, "thread-a")]); ++ const thread = model.threads.find((t) => t.id === "thread-a"); ++ expect(thread?.codevGate ?? null).toBeNull(); ++ expect(thread?.gateRevision).toBe(0); ++ }); ++ ++ /** ++ * The one that catches a dropped mark. If the projector ignored `gateRevision` ++ * every thread would read 0 forever, the decider would allocate 1 on every ++ * write, and criterion 10 would be unenforceable — while the decider's own ++ * tests, which never touch a projected model, stayed green. ++ */ ++ it("carries the mark forward so the next allocation is not 1 again", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ gateSet(3, 1), ++ gateCleared(4, 2), ++ gateSet(5, 3), ++ ]); ++ const thread = model.threads.find((t) => t.id === "thread-a"); ++ expect(thread?.gateRevision).toBe(3); ++ expect(thread?.gateRevision).not.toBe(0); ++ expect(thread?.gateRevision).not.toBe(1); ++ }); ++ ++ it("clearing removes the gate but RAISES the mark", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ gateSet(3, 5), ++ gateCleared(4, 6), ++ ]); ++ const thread = model.threads.find((t) => t.id === "thread-a"); ++ // The block is gone and the mark is higher than the gate that set it. This ++ // is what makes a stale write unable to resurrect an answered gate. ++ expect(thread?.codevGate ?? null).toBeNull(); ++ expect(thread?.gateRevision).toBe(6); ++ }); ++ ++ it("a gate on one thread does not touch another", async () => { ++ const model = await rebuild([ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ preForkThreadCreated(3, "thread-b"), ++ gateSet(4, 1), ++ ]); ++ const other = model.threads.find((t) => t.id === "thread-b"); ++ expect(other?.codevGate ?? null).toBeNull(); ++ expect(other?.gateRevision).toBe(0); ++ }); ++ ++ it("a rebuild over the same log reproduces the mark, not just the gate", async () => { ++ const events = [ ++ projectCreated, ++ preForkThreadCreated(2, "thread-a"), ++ gateSet(3, 1), ++ gateCleared(4, 2), ++ ]; ++ const first = await rebuild(events); ++ const second = await rebuild(events); ++ expect(second.threads.map((t) => [t.id, t.gateRevision, t.codevGate])).toEqual( ++ first.threads.map((t) => [t.id, t.gateRevision, t.codevGate]), ++ ); ++ }); ++}); ++ + describe("projector: codev thread hierarchy (spec 250)", () => { + it("rebuilds a pre-fork event log, reading the hierarchy as not recorded", async () => { + const model = await rebuild([ +diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts +index ae895c634..4613d2f99 100644 +--- a/apps/server/src/ws.ts ++++ b/apps/server/src/ws.ts +@@ -1194,14 +1194,18 @@ const makeWsRpcLayer = ( + const { events } = yield* dispatchFromClient(command).pipe( + Effect.mapError((cause) => + // The decider's own refusal — a stale revision, or no such +- // thread — arrives here already named. Anything else is +- // reported as the thread lookup failing rather than being +- // given a reason it did not have. ++ // thread — arrives here already named, and is passed through ++ // untouched. ++ // ++ // Everything else is CODEV_GATE_WRITE_FAILED, NOT ++ // "thread not found". A database error relabelled as a missing ++ // thread sends a caller to look for a thread that is there, ++ // and hides a broken database behind ordinary caller error. + cause instanceof CodevGateWriteError + ? cause + : new CodevGateWriteError({ +- reason: "CODEV_GATE_THREAD_NOT_FOUND", +- message: `Gate write refused: ${String(cause)}`, ++ reason: "CODEV_GATE_WRITE_FAILED", ++ message: `Gate write failed: ${String(cause)}`, + cause, + }), + ), +@@ -1218,14 +1222,20 @@ const makeWsRpcLayer = ( + committed === undefined || + (committed.type !== "codev.gate-set" && committed.type !== "codev.gate-cleared") + ) { +- // Reported as unconfirmed, never as applied. The write may well +- // have landed; what we cannot do is tell the caller which +- // revision it got, and a guessed revision is worse than none. ++ // Unconfirmed, and spelled as such. This is the ROUTINE retry ++ // path, not a rare defensive one: an idempotent replay of the ++ // same commandId commits nothing and returns no events, landing ++ // here every time. Labelling it THREAD_NOT_FOUND reported a ++ // normal retry as a nonexistent thread. ++ // ++ // The write may well have applied. What cannot be done is name ++ // its revision, and a guessed revision is worse than none. + return yield* new CodevGateWriteError({ +- reason: "CODEV_GATE_THREAD_NOT_FOUND", ++ reason: "CODEV_GATE_WRITE_UNCONFIRMED", + message: +- "Gate write committed but its event could not be read back; the revision is " + +- "unconfirmed. Re-read the thread rather than assuming this write applied.", ++ "Gate write produced no event to read a revision from — most likely a replay " + ++ "of a commandId that already committed. Re-read the thread: do not assume " + ++ "this write applied, and do not assume it did not.", + }); + } + +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index be111a3dd..9f460701f 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -2033,9 +2033,28 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass +Date: Sun, 30 Aug 2026 19:08:27 -0600 +Subject: [PATCH 10/34] [Spec 250][Phase: phase_4] fix: make the compiler + refuse an unclassified refusal +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Architect ruling: not a follow-up. Three occurrences of one mistake in one +project is a structural defect, and a follow-up issue is a promise to hit +it a fourth time. + +isRefusal was a hand-written disjunction of type guards over a structural +union. Adding a member to OrchestrationDispatchError and forgetting the +predicate compiled cleanly and silently dropped the new member — twice in +this spec, both times making a refusal reach the caller as "Failed to +generate an event identifier", and the second time making criterion 10 +false at the wire. + +dispatchErrorKind now classifies EVERY member in a switch whose default +assigns to `never`, and isRefusal reads that classification instead of +keeping its own list. Adding a member without classifying it does not +compile, and the error names the type: + + error TS2322: Type 'ProbeUnclassifiedError' is not assignable to type 'never'. + +Verified by doing exactly that — a fourth member added, the build confirmed +to fail, the member removed. + +At runtime an unrecognised error classifies as internal, which is the safe +direction: a refusal misclassified as internal is a worse message, while an +internal error misclassified as a refusal is a lie about whose fault it +was. + +Three tests assert the classification itself, so a member silently moved +between the two groups fails even though it still compiles. + +Server 2835 passed, 8 skipped. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + ...OrchestrationEngine.codevHierarchy.test.ts | 46 +++++++++- + .../Layers/OrchestrationEngine.ts | 86 ++++++++++++++++--- + 2 files changed, 117 insertions(+), 15 deletions(-) + +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +index ba7084b55..097d5a790 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.codevHierarchy.test.ts +@@ -31,7 +31,7 @@ import { OrchestrationEventStoreLive } from "../../persistence/Layers/Orchestrat + import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; + import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +-import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; ++import { OrchestrationEngineLive, dispatchErrorKind } from "./OrchestrationEngine.ts"; + import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; + import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; + import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +@@ -143,6 +143,50 @@ async function dispatchFailure( + return (outcome as { failure: unknown }).failure; + } + ++/** ++ * The exhaustiveness check that replaced three fixes of the same bug. ++ * ++ * The COMPILER is the real test — adding a member to `OrchestrationDispatchError` ++ * without classifying it fails the build, verified by doing exactly that and ++ * watching tsc name the forgotten type. These assert the classification itself, ++ * so a member silently moved from "refusal" to "internal" also fails. ++ */ ++describe("dispatch error classification (spec 250)", () => { ++ const refusals = [ ++ "OrchestrationCommandInvariantError", ++ "CodevHierarchyInvalidError", ++ "CodevGateWriteError", ++ ] as const; ++ ++ const internals = [ ++ "PersistenceSqlError", ++ "PersistenceDecodeError", ++ "OrchestrationCommandIdConflictError", ++ "OrchestrationCommandPreviouslyRejectedError", ++ "OrchestrationProjectorDecodeError", ++ "OrchestrationListenerCallbackError", ++ ] as const; ++ ++ it("classifies every refusal as a refusal", () => { ++ for (const tag of refusals) { ++ expect(dispatchErrorKind({ _tag: tag } as never), `${tag} must be a refusal`).toBe("refusal"); ++ } ++ }); ++ ++ it("classifies everything else as internal", () => { ++ for (const tag of internals) { ++ expect(dispatchErrorKind({ _tag: tag } as never), `${tag} must be internal`).toBe("internal"); ++ } ++ }); ++ ++ it("treats an unrecognised error as internal, not as a refusal", () => { ++ // The safe direction. A refusal misclassified as internal is a worse ++ // message; an internal error misclassified as a refusal is a lie about ++ // whose fault it was. ++ expect(dispatchErrorKind({ _tag: "SomethingFromTheFuture" } as never)).toBe("internal"); ++ }); ++}); ++ + describe("OrchestrationEngine: codev hierarchy refusals (spec 250)", () => { + it("surfaces the reason discriminant through the engine, not a generic invariant error", async () => { + const system = await seededSystem(); +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +index ac5266f18..a8a4ce3cd 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +@@ -67,25 +67,83 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar + * decider tests stayed green, because they call the decider directly. + */ + const isCodevHierarchyInvalidError = Schema.is(CodevHierarchyInvalidError); +-const isCodevGateWriteError = Schema.is(CodevGateWriteError); ++/** ++ * Every dispatch error, classified — and the compiler will not let you skip one. ++ * ++ * THIS EXISTS BECAUSE THE SAME BUG SHIPPED THREE TIMES. `isRefusal` used to be a ++ * hand-written disjunction of type guards over a structural union. Adding a ++ * member to `OrchestrationDispatchError` and forgetting the predicate compiles ++ * cleanly and silently drops the new member, so: ++ * ++ * - spec 250 phase 3: `CodevHierarchyInvalidError` was rewritten as "Failed to ++ * generate an event identifier" and persisted onto the rejected receipt; ++ * - spec 250 phase 4: `CodevGateWriteError` the same way, which made criterion ++ * 10 false at the wire while every decider test stayed green. ++ * ++ * Both were found by review, not by the suite, because the suite tested one layer ++ * below the one that broke them. A follow-up issue would have been a promise to ++ * hit it a fourth time. ++ * ++ * The `never` assignment in `default` is the mechanism: when every member is ++ * handled, `error` narrows to `never` and the line compiles. Add a member to ++ * `OrchestrationDispatchError` without a case here and it does not — the build ++ * fails at the place that needs the decision, naming the type you forgot. ++ * ++ * At runtime `default` returns `"internal"`, so a foreign error is not a refusal. ++ * That is the safe answer: a refusal misclassified as internal is a worse message, ++ * while an internal error misclassified as a refusal is a lie about whose fault ++ * it was. ++ */ ++type DispatchErrorKind = "refusal" | "internal"; ++ ++export const dispatchErrorKind = (error: OrchestrationDispatchError): DispatchErrorKind => { ++ switch (error._tag) { ++ // Refusals: the command was understood and declined. The caller acts on these. ++ case "OrchestrationCommandInvariantError": ++ case "CodevHierarchyInvalidError": ++ case "CodevGateWriteError": ++ return "refusal"; ++ ++ // Everything else: the command may have been fine and something under it ++ // failed. The caller cannot act on these beyond retrying. ++ case "PersistenceSqlError": ++ case "PersistenceDecodeError": ++ case "OrchestrationCommandIdConflictError": ++ case "OrchestrationCommandPreviouslyRejectedError": ++ case "OrchestrationProjectorDecodeError": ++ case "OrchestrationListenerCallbackError": ++ return "internal"; ++ ++ default: { ++ // COMPILE-TIME EXHAUSTIVENESS. If this line stops compiling, a member was ++ // added to OrchestrationDispatchError and not classified above. Classify ++ // it — and if it is a refusal, that is all you need to do: `isRefusal` ++ // reads this function rather than keeping its own list. ++ const unclassified: never = error; ++ void unclassified; ++ return "internal"; ++ } ++ } ++}; ++ ++/** A dispatch error the caller is meant to act on, as opposed to retry. */ ++export type OrchestrationRefusal = Extract< ++ OrchestrationDispatchError, ++ { readonly _tag: "OrchestrationCommandInvariantError" | "CodevHierarchyInvalidError" | "CodevGateWriteError" } ++>; + + /** + * Errors that describe a REFUSAL and must reach the dispatcher intact. + * +- * EVERY refusal type has to be listed here. Phase 3 fixed this function once, +- * for `CodevHierarchyInvalidError`; phase 4 added a third refusal type and did +- * not extend it, so gate refusals were rewritten as "Failed to generate an event +- * identifier" and criterion 10 was false at the wire while all 11 decider tests +- * stayed green. Both review lanes caught it. Adding a refusal type without adding +- * it here is the same bug a third time. ++ * Derived from `dispatchErrorKind` rather than keeping a second list, so there is ++ * one place to update and the compiler guards it. + */ +-const isRefusal = (cause: unknown): cause is +- | OrchestrationCommandInvariantError +- | CodevHierarchyInvalidError +- | CodevGateWriteError => +- isOrchestrationCommandInvariantError(cause) || +- isCodevHierarchyInvalidError(cause) || +- isCodevGateWriteError(cause); ++const isRefusal = (cause: unknown): cause is OrchestrationRefusal => ++ typeof cause === "object" && ++ cause !== null && ++ "_tag" in cause && ++ typeof (cause as { readonly _tag: unknown })._tag === "string" && ++ dispatchErrorKind(cause as OrchestrationDispatchError) === "refusal"; + + interface CommandEnvelope { + command: OrchestrationCommand; diff --git a/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch b/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch new file mode 100644 index 000000000..ae9becd8e --- /dev/null +++ b/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch @@ -0,0 +1,282 @@ +From 0254c84e1241587c93ce23425271652a9037f05f Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 19:32:36 -0600 +Subject: [PATCH 11/34] [Spec 250][Phase: phase_4] fix: the credential had no + production caller +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Both lanes found it, and it is costume one from the phase 4 review — a +thing tested in isolation that production never builds — produced in the +same phase that added the hot-tier lesson about exactly that. The module +named the scopes, named the path, tested the write, and nothing in the +server ever ran any of it. + +provisionCodevGateWriter is now a named startup phase, +"codev.gate-writer.provision", handed the server's own base dir and +EnvironmentAuth.issueSession. Non-fatal: a server that cannot write the +token is still a working server for every other client, and failing the +whole boot over codev-agent's credential would take the UI down with it. +The failure logs under CODEV_GATE_WRITER_PROVISION_FAILED so it is not met +later as an unexplained authorization error. + +Idempotent by rotation rather than lookup: a fresh session each start, +file overwritten. Reusing an existing token would mean reading a bearer +credential back off disk to decide whether to keep it, and a server that +reads tokens is a larger target than one that only writes them. + +The test asserts against the production source, because "production calls +this" is a fact about the call site, not about the module — the +provisioner passing its own unit tests says nothing about whether the +server runs it. Verified to discriminate: removing the startup phase fails +it. + +Second finding, also fixed: the scope map ROW was asserted and the +ENFORCEMENT was not. A row nothing reads documents an intention. Now +asserts ws.ts routes every RPC through requiredScopeForRpcMethod, on both +the effect and stream wrappers, and that an unmapped method throws rather +than defaulting to permissive. + +Fork typecheck green; server 2839 passed, 8 skipped. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/auth/CodevGateScope.test.ts | 34 ++++++++++ + apps/server/src/codev/gateCredential.test.ts | 67 +++++++++++++++++++- + apps/server/src/codev/gateCredential.ts | 45 +++++++++++++ + apps/server/src/serverRuntimeStartup.ts | 24 +++++++ + 4 files changed, 169 insertions(+), 1 deletion(-) + +diff --git a/apps/server/src/auth/CodevGateScope.test.ts b/apps/server/src/auth/CodevGateScope.test.ts +index 62fea3d7e..28c149640 100644 +--- a/apps/server/src/auth/CodevGateScope.test.ts ++++ b/apps/server/src/auth/CodevGateScope.test.ts +@@ -72,6 +72,40 @@ describe("codev:gate-write scope (spec 250)", () => { + }); + }); + ++describe("the scope map is what the transport actually enforces (spec 250)", () => { ++ /** ++ * Review's second finding: the map ROW was asserted, the ENFORCEMENT was not. ++ * ++ * A row nothing reads is a row that documents an intention. `ws.ts` calls ++ * `requiredScopeForRpcMethod(method)` and hands the result to `authorizeEffect`, ++ * so the row is only load-bearing if that call is on the path every RPC takes. ++ * Asserted against the production source, because "the transport consults this" ++ * is a fact about `ws.ts` and not about the map. ++ */ ++ it("ws.ts authorizes every RPC through the scope map", async () => { ++ const { readFileSync } = await import("node:fs"); ++ const { fileURLToPath } = await import("node:url"); ++ const ws = readFileSync(fileURLToPath(new URL("../ws.ts", import.meta.url)), "utf8"); ++ ++ expect(ws).toContain("requiredScopeForRpcMethod"); ++ // Both wrappers, because a method registered as a stream would otherwise slip ++ // past the effect-only one. ++ expect(ws).toContain("authorizeEffect(requiredScopeForRpcMethod(method)"); ++ expect(ws).toContain("authorizeStream(requiredScopeForRpcMethod(method)"); ++ // And the gate handler goes through the instrumented wrapper rather than ++ // being registered bare, which is what puts it on that path at all. ++ expect(ws).toContain("observeRpcEffect(\n CODEV_WS_METHODS.gateWrite,"); ++ }); ++ ++ it("an unmapped method is refused rather than defaulting to something permissive", () => { ++ // The failure direction that matters: a new RPC with no row must not fall ++ // through to "no scope required". ++ expect(() => requiredScopeForRpcMethod("codev.somethingNobodyMapped")).toThrow( ++ /no declared authorization scope/, ++ ); ++ }); ++}); ++ + describe("codev:gate-write is not obtainable through the OAuth token route (spec 250)", () => { + /** + * The third place the scope must not appear, and the one that is easiest to +diff --git a/apps/server/src/codev/gateCredential.test.ts b/apps/server/src/codev/gateCredential.test.ts +index d588a7f27..806f3574d 100644 +--- a/apps/server/src/codev/gateCredential.test.ts ++++ b/apps/server/src/codev/gateCredential.test.ts +@@ -13,7 +13,7 @@ import { + AuthStandardClientScopes, + } from "@t3tools/contracts"; + import * as NodeServices from "@effect/platform-node/NodeServices"; +-import { assert, describe, it } from "@effect/vitest"; ++import { assert, describe, expect, it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + import * as FileSystem from "effect/FileSystem"; + import * as Path from "effect/Path"; +@@ -58,6 +58,71 @@ describe("codev gate-writer credential (spec 250)", () => { + assert.ok(CODEV_GATE_WRITER_LABEL.includes("gate writer")); + }); + ++ /** ++ * THE TEST THAT WAS MISSING, and both review lanes found its absence. ++ * ++ * The first version of this file named the scopes, named the path, tested the ++ * write — and nothing in production called any of it. That is costume one from ++ * the phase 4 review (a thing tested in isolation that production never ++ * builds), produced in the same phase that added the hot-tier lesson about it. ++ * ++ * Asserted against the production source, because "production calls this" is a ++ * fact about the call site and not about this module. `provisionCodevGateWriter` ++ * passing its own unit tests says nothing about whether the server ever runs it. ++ */ ++ it("is provisioned on the server's startup path, not merely defined", async () => { ++ const { readFileSync } = await import("node:fs"); ++ const { fileURLToPath } = await import("node:url"); ++ const startup = readFileSync( ++ fileURLToPath(new URL("../serverRuntimeStartup.ts", import.meta.url)), ++ "utf8", ++ ); ++ ++ expect(startup, "the startup path must import the provisioner").toContain( ++ "provisionCodevGateWriter", ++ ); ++ expect(startup, "and run it as a named startup phase").toContain( ++ '"codev.gate-writer.provision"', ++ ); ++ // It must be handed the server's own base dir, or the token lands somewhere ++ // codev-agent will not look. ++ expect(startup).toContain("baseDir: serverConfig.baseDir"); ++ }); ++ ++ it("provisions by issuing a session with exactly the declared scopes", async () => { ++ const Effect = await import("effect/Effect"); ++ const { provisionCodevGateWriter } = await import("./gateCredential.ts"); ++ const NodeServices = await import("@effect/platform-node/NodeServices"); ++ const FileSystem = await import("effect/FileSystem"); ++ ++ let requested: ReadonlyArray = []; ++ let subject = ""; ++ ++ await Effect.runPromise( ++ Effect.gen(function* () { ++ const fs = yield* FileSystem.FileSystem; ++ const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "codev-gate-provision-" }); ++ yield* provisionCodevGateWriter({ ++ baseDir, ++ issueSession: (options) => { ++ requested = options.scopes; ++ subject = options.subject; ++ return Effect.succeed({ token: "tok_provisioned" }); ++ }, ++ }); ++ // Written where the operator and codev-agent will look for it. ++ const written = yield* fs.readFileString( ++ `${baseDir}/codev/gate-writer.token`, ++ ); ++ expect(written.trim()).toBe("tok_provisioned"); ++ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)) as Effect.Effect, ++ ); ++ ++ // The scopes actually requested, not the ones the constant claims. ++ expect([...requested].sort()).toEqual([...CODEV_GATE_WRITER_SCOPES].sort()); ++ expect(subject).toBe(CODEV_GATE_WRITER_SUBJECT); ++ }); ++ + it.effect("writes the token 0600 and readable, under the server's base dir", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; +diff --git a/apps/server/src/codev/gateCredential.ts b/apps/server/src/codev/gateCredential.ts +index 04fdc7632..4b233fbb2 100644 +--- a/apps/server/src/codev/gateCredential.ts ++++ b/apps/server/src/codev/gateCredential.ts +@@ -90,3 +90,48 @@ export const writeCodevGateWriterToken = Effect.fn("writeCodevGateWriterToken")( + + return target; + }); ++ ++/** ++ * Provision the credential at server start, idempotently. ++ * ++ * WHY THIS FUNCTION EXISTS AT ALL: the first version of this file named the ++ * scopes and the path, tested both, and **had no production caller**. Both review ++ * lanes caught it. That is the same defect this spec has now produced five times ++ * — a thing tested in isolation that production never builds — and it appeared in ++ * the very phase that added the hot-tier lesson about it. ++ * ++ * Idempotent by rotation rather than by lookup: a fresh session is issued on every ++ * start and the file is overwritten. Reusing an existing token would mean reading ++ * a bearer credential back off disk to decide whether to keep it, and a server ++ * that reads tokens is a larger target than one that only writes them. A stale ++ * session left in the store expires on its own TTL. ++ * ++ * Returns the path so the caller can log it — the operator has to find this file. ++ */ ++export const provisionCodevGateWriter = Effect.fn("provisionCodevGateWriter")(function* (input: { ++ readonly baseDir: string; ++ readonly issueSession: (options: { ++ readonly subject: string; ++ readonly scopes: ReadonlyArray; ++ readonly label: string; ++ }) => Effect.Effect<{ readonly token: string }, unknown>; ++}) { ++ const issued = yield* input.issueSession({ ++ subject: CODEV_GATE_WRITER_SUBJECT, ++ scopes: CODEV_GATE_WRITER_SCOPES, ++ label: CODEV_GATE_WRITER_LABEL, ++ }); ++ ++ const path = yield* writeCodevGateWriterToken({ baseDir: input.baseDir, token: issued.token }); ++ ++ yield* Effect.log("CODEV_GATE_WRITER_PROVISIONED").pipe( ++ Effect.annotateLogs({ ++ subject: CODEV_GATE_WRITER_SUBJECT, ++ scopes: CODEV_GATE_WRITER_SCOPES, ++ path, ++ spec: 250, ++ }), ++ ); ++ ++ return path; ++}); +diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts +index 6bea823b1..03d1fc62a 100644 +--- a/apps/server/src/serverRuntimeStartup.ts ++++ b/apps/server/src/serverRuntimeStartup.ts +@@ -34,6 +34,7 @@ import * as ServerSettings from "./serverSettings.ts"; + import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; + import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; + import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; ++import { provisionCodevGateWriter } from "./codev/gateCredential.ts"; + import * as ProviderService from "./provider/Services/ProviderService.ts"; + import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; + import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +@@ -393,6 +394,8 @@ export const make = (options?: StartupOptions) => + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; ++ // Codev customization (spec 250): the gate-writer credential is issued here. ++ const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + + const commandGate = yield* makeCommandGate; + const httpListening = yield* Deferred.make(); +@@ -442,6 +445,27 @@ export const make = (options?: StartupOptions) => + + yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + ++ // Codev customization (spec 250). The single `codev:gate-write` credential, ++ // provisioned out of band at server start. Non-fatal: a server that cannot ++ // write the token is still a working server for every other client, and ++ // failing the whole boot over `codev-agent`'s credential would take the UI ++ // down with it. The failure is logged under its own signal so it is not ++ // discovered later as an unexplained authorization error. ++ yield* runStartupPhase( ++ "codev.gate-writer.provision", ++ provisionCodevGateWriter({ ++ baseDir: serverConfig.baseDir, ++ issueSession: (options) => environmentAuth.issueSession(options), ++ }).pipe( ++ Effect.asVoid, ++ Effect.catchCause((cause) => ++ Effect.logWarning("CODEV_GATE_WRITER_PROVISION_FAILED").pipe( ++ Effect.annotateLogs({ cause: Cause.pretty(cause), spec: 250 }), ++ ), ++ ), ++ ), ++ ); ++ + const welcomeBase = yield* resolveWelcomeBase; + const environment = yield* serverEnvironment.getDescriptor; + yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch b/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch new file mode 100644 index 000000000..96cff5a6a --- /dev/null +++ b/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch @@ -0,0 +1,206 @@ +From 51b55d4899e4d900dfa0a7995f6f9200c53d10c0 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 20:48:18 -0600 +Subject: [PATCH 12/34] [Spec 250][Phase: phase_4] refactor: derive + OrchestrationRefusal from the classification table +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Iteration-3 review: both lanes APPROVE, three non-blocking findings from the +claude lane, all three fixed here rather than deferred. + +1. `OrchestrationRefusal` hand-listed the same three tags `dispatchErrorKind` + owns. Classifying a fourth refusal without also editing the `Extract` would + have narrowed `isRefusal` to a type excluding it — runtime correct, types + quietly lying. That is the one-list-in-two-places shape that shipped this bug + three times, so it is fixed in phase, not filed. + + The switch is now a `DISPATCH_ERROR_KIND` table under + `as const satisfies { [K in OrchestrationDispatchError["_tag"]]: ... }`. + A missing member is a missing key; an extra one is an excess property. The + refusal type is derived from the table's literal values. + + Verified to discriminate: + - remove the `CodevGateWriteError` row -> TS2741 naming the tag you forgot; + - flip it to "internal" -> 2 engine tests go red, including the one asserting + the stale write reaches the dispatcher as CodevGateWriteError. + +2. The `CodevGateWriteErrorReason` doc comment still said "Three causes" and + documented `CODEV_GATE_SCOPE_REQUIRED`, dropped from the union in iteration 1. + A phase 6/8 consumer would have gone looking for a reason that cannot arrive. + Rewritten to say why the scope case is refused by the transport instead, so + nobody adds it back. + +3. `CodevGateScope.test.ts` asserted exact source indentation. Now a + whitespace-tolerant regex over the same fact. Verified both directions: + registering the gate handler bare fails it; collapsing the call to one line + does not. + +Fork: typecheck green, server 2839 passed / 8 skipped / 1 pre-existing +(entrypoint symlink, unmodified and byte-identical to the base commit). +--- + apps/server/src/auth/CodevGateScope.test.ts | 6 +- + .../Layers/OrchestrationEngine.ts | 97 +++++++++++-------- + packages/contracts/src/orchestration.ts | 14 ++- + 3 files changed, 68 insertions(+), 49 deletions(-) + +diff --git a/apps/server/src/auth/CodevGateScope.test.ts b/apps/server/src/auth/CodevGateScope.test.ts +index 28c149640..7e6be7680 100644 +--- a/apps/server/src/auth/CodevGateScope.test.ts ++++ b/apps/server/src/auth/CodevGateScope.test.ts +@@ -94,7 +94,11 @@ describe("the scope map is what the transport actually enforces (spec 250)", () + expect(ws).toContain("authorizeStream(requiredScopeForRpcMethod(method)"); + // And the gate handler goes through the instrumented wrapper rather than + // being registered bare, which is what puts it on that path at all. +- expect(ws).toContain("observeRpcEffect(\n CODEV_WS_METHODS.gateWrite,"); ++ // Whitespace-tolerant on purpose: the fact under test is "gateWrite is the ++ // first argument to observeRpcEffect", and a prettier reformat must not be ++ // able to fail it. The `toContain` guard above is the positive control that ++ // keeps this from passing vacuously against an unrelated file. ++ expect(ws).toMatch(/observeRpcEffect\(\s*CODEV_WS_METHODS\.gateWrite\s*,/); + }); + + it("an unmapped method is refused rather than defaulting to something permissive", () => { +diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +index a8a4ce3cd..ac2680f89 100644 +--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts ++++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +@@ -84,53 +84,64 @@ const isCodevHierarchyInvalidError = Schema.is(CodevHierarchyInvalidError); + * below the one that broke them. A follow-up issue would have been a promise to + * hit it a fourth time. + * +- * The `never` assignment in `default` is the mechanism: when every member is +- * handled, `error` narrows to `never` and the line compiles. Add a member to +- * `OrchestrationDispatchError` without a case here and it does not — the build +- * fails at the place that needs the decision, naming the type you forgot. +- * +- * At runtime `default` returns `"internal"`, so a foreign error is not a refusal. +- * That is the safe answer: a refusal misclassified as internal is a worse message, +- * while an internal error misclassified as a refusal is a lie about whose fault +- * it was. ++ * The mapped-type `satisfies` on `DISPATCH_ERROR_KIND` below is the mechanism: a ++ * member added to `OrchestrationDispatchError` without a row there is a missing ++ * key, and the build fails at the place that needs the decision, naming the tag ++ * you forgot. Classifying it is all you have to do — both `isRefusal` and the ++ * `OrchestrationRefusal` type read that table rather than keeping their own list. + */ + type DispatchErrorKind = "refusal" | "internal"; + +-export const dispatchErrorKind = (error: OrchestrationDispatchError): DispatchErrorKind => { +- switch (error._tag) { +- // Refusals: the command was understood and declined. The caller acts on these. +- case "OrchestrationCommandInvariantError": +- case "CodevHierarchyInvalidError": +- case "CodevGateWriteError": +- return "refusal"; +- +- // Everything else: the command may have been fine and something under it +- // failed. The caller cannot act on these beyond retrying. +- case "PersistenceSqlError": +- case "PersistenceDecodeError": +- case "OrchestrationCommandIdConflictError": +- case "OrchestrationCommandPreviouslyRejectedError": +- case "OrchestrationProjectorDecodeError": +- case "OrchestrationListenerCallbackError": +- return "internal"; +- +- default: { +- // COMPILE-TIME EXHAUSTIVENESS. If this line stops compiling, a member was +- // added to OrchestrationDispatchError and not classified above. Classify +- // it — and if it is a refusal, that is all you need to do: `isRefusal` +- // reads this function rather than keeping its own list. +- const unclassified: never = error; +- void unclassified; +- return "internal"; +- } +- } +-}; ++/** ++ * The classification, as data, keyed by every dispatch-error tag. ++ * ++ * The mapped-type `satisfies` is the mechanism: a member added to ++ * `OrchestrationDispatchError` and not listed here is a MISSING KEY and the file ++ * does not compile, naming the tag you forgot. A key that is not a member is an ++ * excess property and also does not compile. `as const` keeps the values literal, ++ * which is what lets `OrchestrationRefusal` be derived from this table below ++ * instead of being a second hand-written list of the same three tags. ++ */ ++const DISPATCH_ERROR_KIND = { ++ // Refusals: the command was understood and declined. The caller acts on these. ++ OrchestrationCommandInvariantError: "refusal", ++ CodevHierarchyInvalidError: "refusal", ++ CodevGateWriteError: "refusal", ++ ++ // Everything else: the command may have been fine and something under it ++ // failed. The caller cannot act on these beyond retrying. ++ PersistenceSqlError: "internal", ++ PersistenceDecodeError: "internal", ++ OrchestrationCommandIdConflictError: "internal", ++ OrchestrationCommandPreviouslyRejectedError: "internal", ++ OrchestrationProjectorDecodeError: "internal", ++ OrchestrationListenerCallbackError: "internal", ++} as const satisfies { readonly [K in OrchestrationDispatchError["_tag"]]: DispatchErrorKind }; ++ ++export const dispatchErrorKind = (error: OrchestrationDispatchError): DispatchErrorKind => ++ // The `??` is load-bearing at runtime even though the index signature is total: ++ // `isRefusal` reaches this with an `unknown` cause it has only shape-checked, so ++ // a foreign tag lands here and must answer "internal". That is the safe answer — ++ // a refusal misclassified as internal is a worse message, while an internal ++ // error misclassified as a refusal is a lie about whose fault it was. ++ DISPATCH_ERROR_KIND[error._tag] ?? "internal"; ++ ++/** ++ * A dispatch error the caller is meant to act on, as opposed to retry. ++ * ++ * DERIVED from `DISPATCH_ERROR_KIND`, not hand-listed. The previous spelling ++ * repeated the three refusal tags in an `Extract`, so classifying a fourth refusal ++ * in the table without also editing the `Extract` narrowed `isRefusal` to a type ++ * that excluded it — the runtime stayed correct and the types quietly lied. That ++ * is the same one-list-in-two-places shape that shipped this bug three times. ++ */ ++type RefusalTag = { ++ readonly [K in keyof typeof DISPATCH_ERROR_KIND]: (typeof DISPATCH_ERROR_KIND)[K] extends "refusal" ++ ? K ++ : never; ++}[keyof typeof DISPATCH_ERROR_KIND]; + +-/** A dispatch error the caller is meant to act on, as opposed to retry. */ +-export type OrchestrationRefusal = Extract< +- OrchestrationDispatchError, +- { readonly _tag: "OrchestrationCommandInvariantError" | "CodevHierarchyInvalidError" | "CodevGateWriteError" } +->; ++export type OrchestrationRefusal = Extract; + + /** + * Errors that describe a REFUSAL and must reach the dispatcher intact. +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index 9f460701f..37cc4e914 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -2021,16 +2021,20 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass +Date: Sun, 30 Aug 2026 22:12:03 -0600 +Subject: [PATCH 13/34] [Spec 250][Phase: phase_6] fix: a refusal's + discriminant did not survive the ws boundary +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +MEASURED, against a live server. Phase 6 dispatched four illegal hierarchy edges +over a real socket and read the answers: every one came back as +`OrchestrationDispatchCommandError` with the reason present only inside `message`, +as English, behind a `cause` holding a serialized `Error`. A client could not tell +`parent-not-found` from `parent-not-architect` without parsing a sentence. On the +wire, the discriminant did not exist. + +Phase 3 fixed `OrchestrationEngine` deleting these discriminants. This is one hop +further out, in `ws.ts`, and every test beneath that hop was green — the same +shape a third time, which is why the acceptance criterion for it is a live round +trip and not a unit test. + +`OrchestrationDispatchCommandError` gains an optional `refusal` field carrying the +tag and the machine-readable reason. `bootstrapThreadDisposition` one field above +is the precedent: an optional machine-readable field so a client branches without +reading prose. Optional for the same reason — most dispatch errors are internal +and have no reason to give, and `refusal: null` on all of them would be a claim +rather than an absence. + +`CodevHierarchyInvalidReason` MOVES from `apps/server` into the contract, because +it travels. A vocabulary that reaches a client and is declared only in the server +means every client keeps its own copy of six literals and checks it by hand +against a file it does not import. `Errors.ts` re-exports it, so nothing else +changed. Both reason unions are now declared above the dispatch error, because a +`const` is not hoisted and TDZ is a runtime error rather than a build one. + +The lifting function shape-checks against the contract's own struct instead of +listing which errors carry a reason: a seventh reason travels without editing it, +an error with no reason yields nothing, and a `reason` outside the declared unions +is NOT carried — a client switching exhaustively would otherwise be lied to. + +Four wrapping sites, not one. The test that asserts them found two the first fix +missed, including one that rebuilds an existing dispatch error to add a field and +would have deleted the discriminant while adding it. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/server/src/codev/dispatchRefusal.test.ts | 133 ++++++++++++++++++ + apps/server/src/orchestration/Errors.ts | 32 ++--- + apps/server/src/ws.ts | 51 +++++++ + packages/contracts/src/orchestration.ts | 121 +++++++++++++--- + 4 files changed, 290 insertions(+), 47 deletions(-) + create mode 100644 apps/server/src/codev/dispatchRefusal.test.ts + +diff --git a/apps/server/src/codev/dispatchRefusal.test.ts b/apps/server/src/codev/dispatchRefusal.test.ts +new file mode 100644 +index 000000000..2aca1793f +--- /dev/null ++++ b/apps/server/src/codev/dispatchRefusal.test.ts +@@ -0,0 +1,133 @@ ++/** ++ * Codev customization (spec 250), phase 6 — a refusal's discriminant on the wire. ++ * ++ * WHY THIS FILE EXISTS: phase 6 dispatched an illegal hierarchy edge over a real ++ * socket against a live fork server and read what came back. The reason was ++ * present only inside `message`, as English, behind a `cause` holding a ++ * serialized `Error`. A client could not tell `parent-not-found` from ++ * `parent-not-architect` without parsing a sentence — so on the wire, the ++ * discriminant did not exist. ++ * ++ * Phase 3 fixed `OrchestrationEngine` deleting these. This was one hop further ++ * out, in `ws.ts`, and every test beneath that hop was green — the same shape a ++ * third time. ++ * ++ * These tests are about the SHAPE and the CALL SITES. The end-to-end proof is ++ * `packages/t3-client/live/spec-250-hierarchy.mjs` in the Codev repository, which ++ * runs against a server started from this source; a unit test cannot cross the ++ * boundary it exists to check. ++ */ ++ ++import { assert, describe, it } from "@effect/vitest"; ++import { readFileSync } from "node:fs"; ++import { dirname, join } from "node:path"; ++import { fileURLToPath } from "node:url"; ++import * as Schema from "effect/Schema"; ++ ++import { ++ CodevGateWriteErrorReason, ++ CodevHierarchyInvalidReason, ++ OrchestrationDispatchCommandError, ++ OrchestrationDispatchRefusal, ++} from "@t3tools/contracts"; ++ ++const here = dirname(fileURLToPath(import.meta.url)); ++const wsSource = readFileSync(join(here, "..", "ws.ts"), "utf8"); ++ ++const isRefusal = Schema.is(OrchestrationDispatchRefusal); ++ ++describe("codev: the dispatch error carries a refusal discriminant", () => { ++ it("accepts a hierarchy reason and a gate reason", () => { ++ assert.isTrue(isRefusal({ tag: "CodevHierarchyInvalidError", reason: "parent-not-found" })); ++ assert.isTrue(isRefusal({ tag: "CodevGateWriteError", reason: "CODEV_GATE_REVISION_STALE" })); ++ }); ++ ++ /** ++ * A reason outside the declared unions is NOT carried. ++ * ++ * The lifting function shape-checks against this struct rather than listing ++ * which errors have a `reason`, so an arbitrary `reason` field on some other ++ * error would otherwise ride along. A client switching exhaustively on this ++ * union would then be lied to — which is worse than the reason being absent, ++ * because absence is a case it already has to handle. ++ */ ++ it("refuses a reason that belongs to neither union", () => { ++ assert.isFalse(isRefusal({ tag: "SomethingElse", reason: "not-a-declared-reason" })); ++ assert.isFalse(isRefusal({ tag: "CodevHierarchyInvalidError" })); ++ assert.isFalse(isRefusal({ reason: "parent-not-found" })); ++ }); ++ ++ /** ++ * Both reason unions live in the CONTRACT, because they travel. ++ * ++ * `CodevHierarchyInvalidReason` was in `apps/server/src/orchestration/Errors.ts` ++ * until this phase. A vocabulary that reaches a client and is declared only in ++ * the server means every client keeps its own copy of six string literals and ++ * checks it by hand against a file it does not import. ++ */ ++ it("declares both vocabularies where the wire is described", () => { ++ assert.deepStrictEqual([...CodevHierarchyInvalidReason.literals].sort(), [ ++ "builder-without-parent", ++ "parent-in-other-project", ++ "parent-is-self", ++ "parent-not-architect", ++ "parent-not-found", ++ "parent-on-non-builder", ++ ]); ++ assert.isTrue(CodevGateWriteErrorReason.literals.length > 0); ++ }); ++ ++ it("leaves the field optional, so an internal error claims nothing", () => { ++ const internal = new OrchestrationDispatchCommandError({ message: "the disk went away" }); ++ assert.strictEqual(internal.refusal, undefined); ++ const refused = new OrchestrationDispatchCommandError({ ++ message: "Codev hierarchy invalid (thread.create, parent-not-found): ...", ++ refusal: { tag: "CodevHierarchyInvalidError", reason: "parent-not-found" }, ++ }); ++ assert.strictEqual(refused.refusal?.reason, "parent-not-found"); ++ }); ++}); ++ ++/** ++ * ASSERT THE CALL SITES. ++ * ++ * `ws.ts` wraps a cause into `OrchestrationDispatchCommandError` in TWO places — ++ * the general one and the bootstrap path. Lifting the discriminant at one of them ++ * would leave the other opaque, which is the one-list-two-places shape that has ++ * now produced this class of bug three times in this spec. ++ */ ++describe("codev: both wrapping sites lift the discriminant", () => { ++ it("calls refusalOf wherever a cause becomes a dispatch error", () => { ++ const constructions = wsSource.split("new OrchestrationDispatchCommandError({").slice(1); ++ assert.isAtLeast(constructions.length, 2, "found no construction sites to check"); ++ ++ /** ++ * Every construction that takes a raw cause must LIFT the discriminant, and ++ * every construction that rebuilds an existing dispatch error must FORWARD it. ++ * Both are ways to lose it and they are spelled differently, so both are ++ * checked. This test found two sites the first fix missed, including one that ++ * rebuilds an error to add a field and would have deleted the discriminant ++ * while adding it. ++ */ ++ const wrappingSites = constructions.filter((block) => block.slice(0, 600).includes("cause")); ++ assert.isAtLeast(wrappingSites.length, 3); ++ for (const [index, block] of wrappingSites.entries()) { ++ const head = block.slice(0, 900); ++ assert.isTrue( ++ head.includes("...refusalOf(") || head.includes("{ refusal: dispatchError.refusal }"), ++ `dispatch-error construction ${index} loses the refusal discriminant`, ++ ); ++ } ++ }); ++ ++ it("shape-checks rather than listing which errors have a reason", () => { ++ // To the arrow function's closing brace at column 0, not to the first `};` ++ // in the body — `return {};` contains one, and slicing there cut the function ++ // in half and made this assertion fail against correct code. ++ const start = wsSource.indexOf("const refusalOf = "); ++ const fn = wsSource.slice(start, wsSource.indexOf("\n};", start)); ++ assert.isAbove(fn.length, 50); ++ assert.notInclude(fn, "CodevHierarchyInvalidError\""); ++ assert.include(fn, "isDispatchRefusal("); ++ }); ++}); +diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts +index 5b1eb51b1..6ed642c3b 100644 +--- a/apps/server/src/orchestration/Errors.ts ++++ b/apps/server/src/orchestration/Errors.ts +@@ -1,6 +1,6 @@ + import * as SchemaIssue from "effect/SchemaIssue"; + import * as Schema from "effect/Schema"; +-import type { CodevGateWriteError } from "@t3tools/contracts"; ++import { CodevHierarchyInvalidReason, type CodevGateWriteError } from "@t3tools/contracts"; + + import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; + +@@ -44,30 +44,14 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< + /** + * Codev customization (spec 250) — an illegal hierarchy edge, refused at write time. + * +- * The `reason` is a discriminant, not prose. Five causes share one shape and a +- * caller has to act differently on each: "no such parent" is a retry after the +- * parent lands, "wrong parent role" is a caller bug, "builder without a parent" +- * is a missing field. One generic error for five causes tells a caller that +- * something is wrong and nothing about what to do, so it gets read once and then +- * matched on the message string, which is worse than no discriminant at all. +- * +- * `detail` stays human-readable and is never parsed. ++ * The reason union MOVED to `@t3tools/contracts` in phase 6 and is re-exported ++ * here so every existing reference keeps working. It moved because it travels: ++ * `OrchestrationDispatchCommandError.refusal` carries it to the client, so it is ++ * part of the wire and belongs where the wire is described. Leaving it here meant ++ * every client had to keep its own copy of six string literals and check it by ++ * hand against a file it does not import. + */ +-export const CodevHierarchyInvalidReason = Schema.Literals([ +- /** `parentThreadId` names a thread that does not exist. */ +- "parent-not-found", +- /** The parent exists but belongs to a different project. */ +- "parent-in-other-project", +- /** The thread names itself as its own parent. */ +- "parent-is-self", +- /** The parent exists in this project but is not an architect. */ +- "parent-not-architect", +- /** `role: "builder"` with no `parentThreadId`. A builder is owned by definition. */ +- "builder-without-parent", +- /** `role: "architect"` or no role, carrying a `parentThreadId`. */ +- "parent-on-non-builder", +-]); +-export type CodevHierarchyInvalidReason = typeof CodevHierarchyInvalidReason.Type; ++export { CodevHierarchyInvalidReason } from "@t3tools/contracts"; + + export class CodevHierarchyInvalidError extends Schema.TaggedErrorClass()( + "CodevHierarchyInvalidError", +diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts +index 4613d2f99..bd7451333 100644 +--- a/apps/server/src/ws.ts ++++ b/apps/server/src/ws.ts +@@ -26,6 +26,7 @@ import { + type GitActionProgressEvent, + type GitManagerServiceError, + OrchestrationDispatchCommandError, ++ OrchestrationDispatchRefusal, + type OrchestrationEvent, + type OrchestrationShellStreamEvent, + type OrchestrationShellStreamItem, +@@ -139,6 +140,36 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http + import * as RelayClient from "@t3tools/shared/relayClient"; + const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); + ++/** ++ * Codev customization (spec 250). Lift a refusal's discriminant onto the wire. ++ * ++ * MEASURED. Phase 6 dispatched an illegal hierarchy edge over a real socket ++ * against a live fork server and read the answer: `OrchestrationDispatchCommandError` ++ * with the reason present only inside `message`, as English, and a `cause` holding ++ * a serialized `Error` with no `reason` field. A client could not tell ++ * `parent-not-found` from `parent-not-architect` without parsing a sentence. ++ * ++ * Phase 3 fixed the ENGINE deleting these discriminants. This is one hop further ++ * out, and every test beneath it was green — which is the same shape a third time, ++ * and the reason phase 6's acceptance criterion is a live round trip rather than a ++ * unit test. ++ * ++ * SHAPE-CHECKED, not tag-listed. It reads `reason` off the cause and does not ++ * enumerate which errors have one: a seventh refusal reason added to either union ++ * travels without editing this function, and an error with no `reason` yields ++ * nothing rather than an invented one. `Schema.is` on the contract's own struct is ++ * what decides — so a `reason` outside the declared unions is NOT carried, because ++ * a client switching on it exhaustively would be lied to. ++ */ ++const isDispatchRefusal = Schema.is(OrchestrationDispatchRefusal); ++const refusalOf = (cause: unknown): { readonly refusal?: OrchestrationDispatchRefusal } => { ++ if (typeof cause !== "object" || cause === null) return {}; ++ const tag = (cause as { readonly _tag?: unknown })._tag; ++ const reason = (cause as { readonly reason?: unknown }).reason; ++ const candidate = { tag, reason }; ++ return isDispatchRefusal(candidate) ? { refusal: candidate } : {}; ++}; ++ + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); + +@@ -582,6 +613,8 @@ const makeWsRpcLayer = ( + : new OrchestrationDispatchCommandError({ + message: cause instanceof Error ? cause.message : fallbackMessage, + cause, ++ // Codev customization (spec 250). See `refusalOf`. ++ ...refusalOf(cause), + }); + const randomUUID = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => +@@ -644,6 +677,11 @@ const makeWsRpcLayer = ( + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + cause, ++ // Codev customization (spec 250). The SECOND wrapping site, and it ++ // has to carry the discriminant too. Fixing only the first would ++ // leave a bootstrap-path refusal opaque for exactly the reason the ++ // whole class of bug keeps recurring: one list, two places. ++ ...refusalOf(error), + }); + }; + +@@ -1083,6 +1121,15 @@ const makeWsRpcLayer = ( + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), ++ // Codev customization (spec 250). FORWARDED, not ++ // dropped. This site rebuilds an error that already ++ // exists in order to add the disposition, so every ++ // field it does not copy is a field it deletes — and ++ // the one being added here is the discriminant a ++ // caller branches on. ++ ...(dispatchError.refusal !== undefined ++ ? { refusal: dispatchError.refusal } ++ : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, +@@ -1340,6 +1387,10 @@ const makeWsRpcLayer = ( + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, ++ // Codev customization (spec 250). The dispatchCommand RPC's ++ // own wrapping site — the one an ordinary client actually ++ // hits, and the one the live wire test measured. ++ ...refusalOf(cause), + }), + ), + ), +diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts +index 37cc4e914..fa0f7a6f5 100644 +--- a/packages/contracts/src/orchestration.ts ++++ b/packages/contracts/src/orchestration.ts +@@ -2009,33 +2009,46 @@ export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( +- "OrchestrationDispatchCommandError", +- { +- message: TrimmedNonEmptyString, +- cause: Schema.optional(Schema.Defect()), +- bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")), +- }, +-) {} +- ++// Codev customization (spec 250). Both reason unions are declared HERE, above ++// `OrchestrationDispatchCommandError`, because it now carries one of them and a ++// `const` is not hoisted — declared after it, the module raises a TDZ error at ++// evaluation rather than a type error at build. + /** +- * Codev customization (spec 250). A refused gate write, with a named reason. ++ * Codev customization (spec 250). Why an illegal hierarchy edge was refused. + * +- * Four causes that a caller must act on differently. The literals below carry +- * the two that need argument; these two are the plain ones: +- * CODEV_GATE_REVISION_STALE the write carried a revision at or below the +- * current high-water mark. Equal counts: two +- * writers that computed the same number are +- * colliding, not agreeing. +- * CODEV_GATE_THREAD_NOT_FOUND no such thread. ++ * The `reason` is a discriminant, not prose. Six causes share one shape and a ++ * caller has to act differently on each: "no such parent" is a retry after the ++ * parent lands, "wrong parent role" is a caller bug, "builder without a parent" ++ * is a missing field. One generic error for six causes tells a caller that ++ * something is wrong and nothing about what to do, so it gets read once and then ++ * matched on the message string, which is worse than no discriminant at all. + * +- * A missing `codev:gate-write` scope is deliberately NOT a reason here. It is +- * refused by the transport as `EnvironmentAuthorizationError` carrying +- * `requiredScope: "codev:gate-write"`, before the handler runs — already +- * distinguishable from an unauthenticated 401 and from any other scope failure. +- * A reason literal for it would be unreachable except from a test that bypassed +- * production, so do not add one back when you go looking for it. ++ * IT LIVES IN THE CONTRACT because it travels. Phase 6 measured the round trip ++ * against a live fork server and found the discriminant did NOT survive: the ws ++ * layer flattened every refusal into `OrchestrationDispatchCommandError` with the ++ * reason only inside the message string. It is carried as a field now ++ * (`OrchestrationDispatchCommandError.refusal`), and a field on the wire belongs ++ * where the wire is described — not in `apps/server`, where every client would ++ * have to keep its own copy of six literals and check it by hand. ++ * ++ * The `detail` on the server's error stays human-readable and is never parsed. + */ ++export const CodevHierarchyInvalidReason = Schema.Literals([ ++ /** `parentThreadId` names a thread that does not exist. */ ++ "parent-not-found", ++ /** The parent exists but belongs to a different project. */ ++ "parent-in-other-project", ++ /** The thread names itself as its own parent. */ ++ "parent-is-self", ++ /** The parent exists in this project but is not an architect. */ ++ "parent-not-architect", ++ /** `role: "builder"` with no `parentThreadId`. A builder is owned by definition. */ ++ "builder-without-parent", ++ /** `role: "architect"` or no role, carrying a `parentThreadId`. */ ++ "parent-on-non-builder", ++]); ++export type CodevHierarchyInvalidReason = typeof CodevHierarchyInvalidReason.Type; ++ + export const CodevGateWriteErrorReason = Schema.Literals([ + "CODEV_GATE_REVISION_STALE", + "CODEV_GATE_THREAD_NOT_FOUND", +@@ -2062,6 +2075,68 @@ export const CodevGateWriteErrorReason = Schema.Literals([ + ]); + export type CodevGateWriteErrorReason = typeof CodevGateWriteErrorReason.Type; + ++/** ++ * Codev customization (spec 250). A refusal's discriminant, carried to the client. ++ * ++ * MEASURED, not assumed. Phase 6 dispatched an illegal hierarchy edge over a real ++ * socket against a live fork server and read what came back: the reason was ++ * present only inside `message`, as English, behind a `cause` holding a ++ * serialized `Error`. A caller could not tell "no such parent" from "wrong parent ++ * role" without parsing a sentence — so, on the wire, the discriminant did not ++ * exist. Phase 3 fixed the ENGINE deleting these; the ws layer was flattening ++ * them one hop further out, and every test beneath that hop was green. ++ * ++ * `bootstrapThreadDisposition` one field above is the precedent this follows: an ++ * optional, machine-readable field on the dispatch error so a client can branch ++ * without reading prose. Optional for the same reason — most dispatch errors are ++ * internal and have no reason to give, and `refusal: null` on every one of them ++ * would be a claim rather than an absence. ++ */ ++export const OrchestrationDispatchRefusal = Schema.Struct({ ++ /** The refusing error's tag, e.g. `"CodevHierarchyInvalidError"`. */ ++ tag: TrimmedNonEmptyString, ++ /** ++ * Its machine-readable reason. ++ * ++ * A union of the reason vocabularies that actually travel, not a bare string: ++ * a client switching on this gets exhaustiveness from the compiler, which is ++ * the whole difference between a discriminant and a label. ++ */ ++ reason: Schema.Union([CodevHierarchyInvalidReason, CodevGateWriteErrorReason]), ++}); ++export type OrchestrationDispatchRefusal = typeof OrchestrationDispatchRefusal.Type; ++ ++export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass()( ++ "OrchestrationDispatchCommandError", ++ { ++ message: TrimmedNonEmptyString, ++ cause: Schema.optional(Schema.Defect()), ++ bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")), ++ // Codev customization (spec 250). See `OrchestrationDispatchRefusal`. ++ refusal: Schema.optional(OrchestrationDispatchRefusal), ++ }, ++) {} ++ ++/** ++ * Codev customization (spec 250). A refused gate write, with a named reason. ++ * ++ * Four causes that a caller must act on differently. The literals below carry ++ * the two that need argument; these two are the plain ones: ++ * CODEV_GATE_REVISION_STALE the write carried a revision at or below the ++ * current high-water mark. Equal counts: two ++ * writers that computed the same number are ++ * colliding, not agreeing. ++ * CODEV_GATE_THREAD_NOT_FOUND no such thread. ++ * ++ * A missing `codev:gate-write` scope is deliberately NOT a reason here. It is ++ * refused by the transport as `EnvironmentAuthorizationError` carrying ++ * `requiredScope: "codev:gate-write"`, before the handler runs — already ++ * distinguishable from an unauthenticated 401 and from any other scope failure. ++ * A reason literal for it would be unreachable except from a test that bypassed ++ * production, so do not add one back when you go looking for it. ++ */ ++ ++ + export class CodevGateWriteError extends Schema.TaggedErrorClass()( + "CodevGateWriteError", + { diff --git a/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch b/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch new file mode 100644 index 000000000..16a1bdacb --- /dev/null +++ b/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch @@ -0,0 +1,514 @@ +From 4633e0a7f4982785a43b265f23280d17d139df07 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 23:10:42 -0600 +Subject: [PATCH 14/34] [Spec 250][Phase: phase_7] feat: the Workspace > + Architect > Builders grouping, as a function +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +t3code's model is Project to Thread, flat. Phase 2 put the two fields that express +three levels on the thread record; this turns a flat list of shells into the tree +the sidebar renders. + +COMPOSED, not folded in. `sidebarProjectGrouping.ts` groups ENVIRONMENTS — +local-only / remote-only / mixed, with a WSL sandbox distinguished from a real +remote. That is a different axis from architect-to-builder nesting, and tangling +the two would join two questions that have nothing to say to each other. This runs +over the threads inside a group that module has already formed. + +Three buckets, and none is a guess. An architect with no builders is still a +subtree, because the moment a human most wants to see one is just after spawning +it. A thread with no role keeps the flat presentation it has always had and nothing +in the tree claims it — inventing a role for a thread Codev did not create turns +"we do not know" into an assertion. A builder whose architect is not here is named +as orphaned, with the id it named and the reason, because dropping it hides a +running agent and re-parenting it to the nearest architect is a guess rendered as a +fact. + +It does not re-parent an orphan even when exactly one architect is on screen, where +the guess would look right nearly every time — and be wrong on the one occasion +someone is trying to work out what happened. + +`parent-not-architect` and `no-parent` are states phase 3 refuses at write time. +They are still carried, because "cannot exist" is a claim about the server and not +about the screen, and a renderer that assumes an impossible state is impossible has +no way to show one when it happens. + +**The seam is checked by the compiler, and it caught two things.** Every test here +builds its own row type, which is the right way to test grouping and the wrong way +to learn whether it fits anything real. Two assignments from `SidebarThreadSummary` +and `Thread` fixed that: + +- the module keyed on `threadId`, the COMMAND spelling, while both read models call + it `id` — and the tests passed against a row type that agreed with the mistake; +- `role?: X` does not accept `undefined` under `exactOptionalPropertyTypes`, so the + interface described a shape no caller has until `| undefined` was written out. + +Neither would have surfaced until the call site, where it reads as "the sidebar +sees no hierarchy" rather than as a type error. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/codev/hierarchy.test.ts | 274 +++++++++++++++++++++++++++ + apps/web/src/codev/hierarchy.ts | 170 +++++++++++++++++ + 2 files changed, 444 insertions(+) + create mode 100644 apps/web/src/codev/hierarchy.test.ts + create mode 100644 apps/web/src/codev/hierarchy.ts + +diff --git a/apps/web/src/codev/hierarchy.test.ts b/apps/web/src/codev/hierarchy.test.ts +new file mode 100644 +index 000000000..593839f25 +--- /dev/null ++++ b/apps/web/src/codev/hierarchy.test.ts +@@ -0,0 +1,274 @@ ++/** ++ * Codev customization (spec 250), phase 7 — the grouping, tested as a function. ++ * ++ * The component applies what this returns, so every claim about the shape of the ++ * tree is settled here rather than through a rendered DOM. The one property every ++ * test leans on: **every input thread appears exactly once in the result.** A ++ * renderer that drops a thread loses a running agent from the only place a human ++ * looks for one, and "it is not in the tree" and "it does not exist" look the same ++ * on screen. ++ */ ++ ++import { describe, expect, it } from "vite-plus/test"; ++ ++import type { SidebarThreadSummary, Thread } from "../types"; ++import { buildCodevHierarchy, hasCodevHierarchy, type CodevHierarchyFields } from "./hierarchy"; ++ ++/** ++ * THE SEAM, checked by the compiler. ++ * ++ * Every test below builds its own `Row`, which is the right way to test the ++ * grouping and the wrong way to find out whether it fits anything real. The first ++ * draft of this module keyed on `threadId` — the COMMAND spelling — while both ++ * read models call it `id`, and every test passed against a row type that ++ * happened to agree with the mistake. ++ * ++ * These two assignments are the check. They compile only if the sidebar's actual ++ * thread types satisfy the module's input, so a rename on either side is a build ++ * error here rather than a `buildCodevHierarchy(threads)` that silently sees no ++ * hierarchy at the call site. ++ */ ++const _sidebarRowsFit: (rows: readonly SidebarThreadSummary[]) => unknown = buildCodevHierarchy; ++const _threadsFit: (rows: readonly Thread[]) => unknown = buildCodevHierarchy; ++void _sidebarRowsFit; ++void _threadsFit; ++ ++interface Row extends CodevHierarchyFields { ++ readonly title: string; ++} ++ ++const architect = (id: string, title = id): Row => ({ id, title, role: "architect" }); ++const builder = (id: string, parentThreadId: string | null, title = id): Row => ({ ++ id, ++ title, ++ role: "builder", ++ parentThreadId, ++}); ++const plain = (id: string, title = id): Row => ({ id, title }); ++ ++/** ++ * An element, asserted rather than indexed. ++ * ++ * Same reason as `onlyOrphan`: under `noUncheckedIndexedAccess` a bare index is ++ * `T | undefined`, and a test that reaches through one is asserting the length by ++ * accident. This says out loud that the element is expected to be there. ++ */ ++function at(items: readonly T[], index: number): T { ++ const item = items[index]; ++ if (!item) throw new Error(`expected an element at index ${index}, saw ${items.length}`); ++ return item; ++} ++ ++/** ++ * The single orphan, asserted rather than indexed. ++ * ++ * The suite runs under `noUncheckedIndexedAccess`, so `orphaned[0]` is ++ * `T | undefined` and a test that reaches through it is asserting the length by ++ * accident. This says the length out loud and hands back a value that exists. ++ */ ++function onlyOrphan(result: ReturnType>) { ++ expect(result.orphaned).toHaveLength(1); ++ const orphan = result.orphaned[0]; ++ if (!orphan) throw new Error("unreachable: length asserted above"); ++ return orphan; ++} ++ ++/** Every input appears exactly once, somewhere. */ ++function accountsForEveryThread(input: readonly Row[]): void { ++ const result = buildCodevHierarchy(input); ++ const seen = [ ++ ...result.architects.flatMap((subtree) => [subtree.architect, ...subtree.builders]), ++ ...result.unmanaged, ++ ...result.orphaned.map((entry) => entry.thread), ++ ].map((thread) => thread.id); ++ expect([...seen].sort()).toEqual(input.map((thread) => thread.id).sort()); ++ expect(new Set(seen).size, "a thread appeared in two buckets").toBe(seen.length); ++} ++ ++describe("codev hierarchy: one architect and its builders", () => { ++ const rows = [architect("arch-1"), builder("b-1", "arch-1"), builder("b-2", "arch-1"), builder("b-3", "arch-1")]; ++ ++ it("nests three builders under their architect", () => { ++ const result = buildCodevHierarchy(rows); ++ expect(result.architects).toHaveLength(1); ++ expect(at(result.architects, 0).architect.id).toBe("arch-1"); ++ expect(at(result.architects, 0).builders.map((b) => b.id)).toEqual(["b-1", "b-2", "b-3"]); ++ expect(result.unmanaged).toEqual([]); ++ expect(result.orphaned).toEqual([]); ++ accountsForEveryThread(rows); ++ }); ++ ++ /** ++ * An architect that has spawned nothing is still a subtree. ++ * ++ * Dropping it would make an architect appear only once it had children, so the ++ * moment a human most wants to see it — just after spawning it, before it has ++ * done anything — is the moment it is invisible. ++ */ ++ it("keeps an architect with no builders", () => { ++ const result = buildCodevHierarchy([architect("arch-alone")]); ++ expect(result.architects).toHaveLength(1); ++ expect(at(result.architects, 0).builders).toEqual([]); ++ }); ++}); ++ ++describe("codev hierarchy: two architects in one project", () => { ++ const rows = [ ++ architect("arch-a"), ++ architect("arch-b"), ++ builder("a-1", "arch-a"), ++ builder("b-1", "arch-b"), ++ builder("a-2", "arch-a"), ++ ]; ++ ++ it("renders two subtrees, each owning its own builders", () => { ++ const result = buildCodevHierarchy(rows); ++ expect(result.architects.map((s) => s.architect.id)).toEqual(["arch-a", "arch-b"]); ++ expect(at(result.architects, 0).builders.map((b) => b.id)).toEqual(["a-1", "a-2"]); ++ expect(at(result.architects, 1).builders.map((b) => b.id)).toEqual(["b-1"]); ++ accountsForEveryThread(rows); ++ }); ++ ++ /** ++ * INPUT ORDER, not an order this module invents. ++ * ++ * The caller has already sorted — by pin, by recency, by whatever the user ++ * chose — and re-sorting here would override that silently, which is the kind ++ * of change nobody can find the cause of. ++ */ ++ it("preserves the order it was given, for architects and for builders", () => { ++ const reversed = buildCodevHierarchy([...rows].reverse()); ++ expect(reversed.architects.map((s) => s.architect.id)).toEqual(["arch-b", "arch-a"]); ++ expect(at(reversed.architects, 1).builders.map((b) => b.id)).toEqual(["a-2", "a-1"]); ++ }); ++}); ++ ++describe("codev hierarchy: threads Codev did not create", () => { ++ /** ++ * The deliverable: they keep the EXISTING flat presentation, in their own ++ * section, and nothing in the tree claims them. ++ */ ++ it("puts a thread with no role in unmanaged, untouched", () => { ++ const rows = [plain("upstream-1"), architect("arch-1"), builder("b-1", "arch-1"), plain("upstream-2")]; ++ const result = buildCodevHierarchy(rows); ++ expect(result.unmanaged.map((t) => t.id)).toEqual(["upstream-1", "upstream-2"]); ++ expect(at(result.architects, 0).builders.map((b) => b.id)).toEqual(["b-1"]); ++ accountsForEveryThread(rows); ++ }); ++ ++ /** ++ * `undefined` and `null` are one fact, not two. ++ * ++ * The wire spells an unset optional both ways — `Schema.optional(NullOr(...))` ++ * on the read models — so a UI that told them apart would be showing the reader ++ * a difference between two spellings. ++ */ ++ it("treats an absent role and an explicit null role identically", () => { ++ const result = buildCodevHierarchy([ ++ { id: "absent", title: "absent" }, ++ { id: "explicit", title: "explicit", role: null, parentThreadId: null }, ++ ]); ++ expect(result.unmanaged.map((t) => t.id)).toEqual(["absent", "explicit"]); ++ expect(result.orphaned).toEqual([]); ++ }); ++ ++ /** ++ * A project with no Codev threads must look exactly as it did before spec 250. ++ * ++ * An empty "Architects" heading above the flat list would be new furniture in ++ * every upstream user's sidebar for a feature they do not have. ++ */ ++ it("reports no hierarchy for a purely upstream project", () => { ++ expect(hasCodevHierarchy([plain("a"), plain("b")])).toBe(false); ++ expect(hasCodevHierarchy([plain("a"), architect("arch")])).toBe(true); ++ expect(hasCodevHierarchy([builder("b", "gone")])).toBe(true); ++ expect(hasCodevHierarchy([])).toBe(false); ++ }); ++}); ++ ++describe("codev hierarchy: orphans are named, not dropped and not re-parented", () => { ++ /** ++ * The ordinary case: the architect was archived, deleted, or is simply not in ++ * this slice. The builder is still running and a human still needs to find it. ++ */ ++ it("reports a builder whose architect is not present", () => { ++ const rows = [architect("arch-1"), builder("b-1", "arch-1"), builder("lost", "arch-gone")]; ++ const result = buildCodevHierarchy(rows); ++ const lost = onlyOrphan(result); ++ expect(lost.thread.id).toBe("lost"); ++ expect(lost.reason).toBe("parent-missing"); ++ // The id it named, so a human can go looking rather than guess. ++ expect(lost.parentThreadId).toBe("arch-gone"); ++ accountsForEveryThread(rows); ++ }); ++ ++ /** ++ * NOT re-parented to the only architect available. ++ * ++ * With exactly one architect on screen, attaching the orphan to it would look ++ * right nearly every time and be a guess every time. The one occasion it is ++ * wrong is the occasion a human is trying to work out what happened. ++ */ ++ it("does not attach an orphan to the only architect in sight", () => { ++ const result = buildCodevHierarchy([architect("arch-1"), builder("lost", "arch-gone")]); ++ expect(at(result.architects, 0).builders).toEqual([]); ++ expect(result.orphaned).toHaveLength(1); ++ }); ++ ++ /** ++ * States phase 3 refuses at write time. ++ * ++ * A server that accepted the thread cannot produce either, so this asserts the ++ * renderer INVENTS NO FALLBACK for them rather than asserting they happen. The ++ * plan asks for exactly this: a state that cannot exist still needs a rendering, ++ * because "cannot exist" is a claim about the server and not about the screen. ++ */ ++ it("reports a builder whose parent is not an architect, without nesting it", () => { ++ const rows = [architect("arch-1"), builder("b-1", "arch-1"), builder("nested", "b-1")]; ++ const result = buildCodevHierarchy(rows); ++ expect(at(result.architects, 0).builders.map((b) => b.id)).toEqual(["b-1"]); ++ const nested = onlyOrphan(result); ++ expect(nested.reason).toBe("parent-not-architect"); ++ accountsForEveryThread(rows); ++ }); ++ ++ it("reports a builder with no parent at all", () => { ++ const result = buildCodevHierarchy([builder("solo", null)]); ++ expect(result.orphaned).toHaveLength(1); ++ const solo = onlyOrphan(result); ++ expect(solo.reason).toBe("no-parent"); ++ expect(solo.parentThreadId).toBeNull(); ++ }); ++ ++ /** ++ * A builder whose parent lives in another project. ++ * ++ * Phase 3 refuses this at write time too, and from the renderer's side it is ++ * indistinguishable from an archived parent — the sidebar is given one project's ++ * threads, so a cross-project parent is simply not in the list. It lands in ++ * `parent-missing`, which is the honest answer: this module cannot see the other ++ * project and must not claim to know why the parent is absent. ++ */ ++ it("reports a cross-project parent as missing, because that is what it can see", () => { ++ const result = buildCodevHierarchy([architect("arch-1"), builder("cross", "arch-elsewhere")]); ++ const cross = onlyOrphan(result); ++ expect(cross.reason).toBe("parent-missing"); ++ expect(cross.parentThreadId).toBe("arch-elsewhere"); ++ }); ++}); ++ ++describe("codev hierarchy: everything is accounted for", () => { ++ it("places every thread exactly once across a mixed project", () => { ++ accountsForEveryThread([ ++ plain("upstream-1"), ++ architect("arch-a"), ++ builder("a-1", "arch-a"), ++ architect("arch-b"), ++ builder("b-1", "arch-b"), ++ builder("lost", "arch-gone"), ++ builder("solo", null), ++ plain("upstream-2"), ++ architect("arch-empty"), ++ ]); ++ }); ++}); +diff --git a/apps/web/src/codev/hierarchy.ts b/apps/web/src/codev/hierarchy.ts +new file mode 100644 +index 000000000..b8319156e +--- /dev/null ++++ b/apps/web/src/codev/hierarchy.ts +@@ -0,0 +1,170 @@ ++/** ++ * Codev customization (spec 250), phase 7 — Workspace > Architect > Builders. ++ * ++ * t3code's model is Project to Thread, flat. Codev's is three levels, and phase 2 ++ * put the two fields that express it on the thread record. This turns a flat list ++ * of shells into the tree the sidebar renders. ++ * ++ * ## It is pure, and it composes rather than extends ++ * ++ * `sidebarProjectGrouping.ts` groups ENVIRONMENTS — `local-only` / `remote-only` / ++ * `mixed`, with `allRemoteMembersAreDesktopLocal` separating a WSL sandbox from a ++ * real remote. That is a different axis from architect-to-builder nesting, and ++ * folding this into it would tangle two questions that have nothing to say to each ++ * other. This runs over the threads INSIDE a group that module has already formed. ++ * ++ * ## Three buckets, and none of them is a guess ++ * ++ * A thread lands in exactly one of: ++ * ++ * architects `role: "architect"`, each carrying the builders that name it. ++ * An architect with no builders is still a subtree: "this architect ++ * has spawned nothing yet" is a real and readable state. ++ * unmanaged no role. Upstream t3code threads are the common case, and they ++ * keep the flat presentation they have always had. NOTHING in the ++ * tree claims them — inventing a role for a thread Codev did not ++ * create would turn "we do not know" into an assertion. ++ * orphaned a builder whose architect is not here. Named, with the reason, ++ * because the alternatives are worse: dropping it hides a running ++ * agent, and re-parenting it to the nearest architect is a guess ++ * rendered as a fact. ++ * ++ * ## Input order is preserved ++ * ++ * The caller has already sorted — by pin, by recency, by whatever the user chose — ++ * and re-sorting here would silently override that. Architects appear in the order ++ * they arrived; each architect's builders likewise. ++ */ ++ ++/** ++ * The two fields phase 2 added, and the id they are keyed by. ++ * ++ * `id`, not `threadId`. Both read models — `OrchestrationThread` and ++ * `OrchestrationThreadShell` — call it `id`; only the COMMANDS say `threadId`. ++ * The first draft of this module keyed on `threadId` and its tests defined their ++ * own row type that happened to have one, so the module compiled and passed ++ * against a shape no caller has. Caught by reading the contract before wiring the ++ * call site, which is the only place it would have surfaced. ++ */ ++export interface CodevHierarchyFields { ++ readonly id: string; ++ /** ++ * `| undefined` is written out, and it is load-bearing. ++ * ++ * The web app compiles with `exactOptionalPropertyTypes`, under which `role?: X` ++ * means "may be absent" and NOT "may be undefined" — so a real thread, whose ++ * type is `X | null | undefined`, does not satisfy it. The compile-time seam ++ * check in the test file is what surfaced that; without the explicit `undefined` ++ * this interface described a shape no caller has. ++ */ ++ readonly role?: "architect" | "builder" | null | undefined; ++ /** Names the PARENT's `id`. The command field keeps its `threadId` spelling. */ ++ readonly parentThreadId?: string | null | undefined; ++} ++ ++/** ++ * Why a builder could not be placed under an architect. ++ * ++ * `parent-missing` is the ordinary one: the architect is archived, deleted, or ++ * simply not in this project's slice. `parent-not-architect` and `no-parent` are ++ * states phase 3 refuses at write time, so they cannot arise from a server that ++ * accepted the thread — they are carried anyway, because a renderer that assumes ++ * an impossible state is impossible has no way to show one when it happens. ++ */ ++export type OrphanReason = "parent-missing" | "parent-not-architect" | "no-parent"; ++ ++export interface OrphanedBuilder { ++ readonly thread: T; ++ readonly reason: OrphanReason; ++ /** The parent it named, when it named one. Shown so a human can go looking. */ ++ readonly parentThreadId: string | null; ++} ++ ++export interface ArchitectSubtree { ++ readonly architect: T; ++ readonly builders: readonly T[]; ++} ++ ++export interface CodevThreadHierarchy { ++ readonly architects: readonly ArchitectSubtree[]; ++ readonly unmanaged: readonly T[]; ++ readonly orphaned: readonly OrphanedBuilder[]; ++} ++ ++/** ++ * Group a flat list of threads into the three-level tree. ++ * ++ * Every input thread appears exactly once in the result. That is the property the ++ * tests are written around: a renderer that drops a thread loses a running agent ++ * from the only place a human looks for one. ++ */ ++export function buildCodevHierarchy( ++ threads: readonly T[], ++): CodevThreadHierarchy { ++ const architectIds = new Set(); ++ for (const thread of threads) { ++ if (thread.role === "architect") architectIds.add(thread.id); ++ } ++ const present = new Set(threads.map((thread) => thread.id)); ++ ++ const buildersByArchitect = new Map(); ++ const unmanaged: T[] = []; ++ const orphaned: OrphanedBuilder[] = []; ++ ++ for (const thread of threads) { ++ // Absent and null are the same answer here — "Codev did not create this" — ++ // and they arrive as both because the wire spells an unset optional either ++ // way. Distinguishing them in the UI would be distinguishing two spellings of ++ // one fact. ++ if (thread.role !== "architect" && thread.role !== "builder") { ++ unmanaged.push(thread); ++ continue; ++ } ++ if (thread.role === "architect") continue; ++ ++ const parentThreadId = thread.parentThreadId ?? null; ++ if (parentThreadId === null) { ++ orphaned.push({ thread, reason: "no-parent", parentThreadId: null }); ++ continue; ++ } ++ if (!present.has(parentThreadId)) { ++ orphaned.push({ thread, reason: "parent-missing", parentThreadId }); ++ continue; ++ } ++ if (!architectIds.has(parentThreadId)) { ++ // The parent is here and is not an architect. Phase 3 refuses this at write ++ // time, so a server that accepted the thread cannot produce it — and the ++ // renderer still does not fall back to nesting it under a non-architect, ++ // because a fallback for an impossible state is a fallback nobody will ever ++ // read the code for again. ++ orphaned.push({ thread, reason: "parent-not-architect", parentThreadId }); ++ continue; ++ } ++ const siblings = buildersByArchitect.get(parentThreadId); ++ if (siblings) siblings.push(thread); ++ else buildersByArchitect.set(parentThreadId, [thread]); ++ } ++ ++ const architects: ArchitectSubtree[] = []; ++ for (const thread of threads) { ++ if (thread.role !== "architect") continue; ++ architects.push({ ++ architect: thread, ++ builders: buildersByArchitect.get(thread.id) ?? [], ++ }); ++ } ++ ++ return { architects, unmanaged, orphaned }; ++} ++ ++/** ++ * Does this list carry any Codev hierarchy at all? ++ * ++ * The sidebar uses it to decide whether to render the tree chrome. A project with ++ * no architects and no builders must look exactly as it did before spec 250 — an ++ * empty "Architects" heading above the flat list would be new furniture in every ++ * upstream user's sidebar for a feature they do not have. ++ */ ++export function hasCodevHierarchy(threads: readonly T[]): boolean { ++ return threads.some((thread) => thread.role === "architect" || thread.role === "builder"); ++} diff --git a/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch b/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch new file mode 100644 index 000000000..e2705ca92 --- /dev/null +++ b/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch @@ -0,0 +1,746 @@ +From 90a5a2d3a3123f8eae7d280ed46fd6e7b3640db1 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 23:22:52 -0600 +Subject: [PATCH 15/34] [Spec 250][Phase: phase_7] feat: the sidebar draws + Workspace > Architect > Builders +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The grouping was a function with nobody calling it. This is the call site: the +sidebar's active list is ordered by it, and the tree is drawn from that order. + +**The section boundary was the thing this phase had to get right.** The sidebar +splits a project into Pinned / Active / Snoozed / Settled BEFORE any grouping +runs, so the tree is built over one of those lists. A builder whose architect the +user has pinned is then looking at a list its parent is not in — and the first +draft reported `parent-missing` for it, three rows below the architect the user +can see. So the grouping now takes `alsoVisible`, the rest of the sidebar, and +answers `parent-elsewhere` instead. Role still outranks section: a non-architect +parent is `parent-not-architect` wherever it sits, because letting a section +boundary change what a thread IS would make the reason a fact about the sidebar +rather than about the data. + +Reading that lookup with `get() !== undefined` was a second bug, and its test +caught it: a roleless thread's role IS `undefined`, so the value could not tell +"not in another section" from "in another section, with no role" — which is the +`parent-not-architect` case the branch exists to name. It reads `has` now. + +**The order returned IS the ordered list.** `orderedThreads` is not only a render +order: shift-range-select and jump-hint labels are assigned from it. A component +that reordered rows while leaving that list alone would draw a correct tree whose +keyboard reached the wrong rows — every row in the right place, and nothing on +screen to show it. So the entries come back in one order and the caller both +renders in it and derives `orderedThreads` from it. + +Nothing changes for a project with no Codev roles. `hasHierarchy` is false there +and the renderer takes the loop it has always had — same rows, same order, no +wrappers, no headings. An empty tree's chrome is still new furniture in every +upstream user's sidebar for a feature they do not have. + +Four orphan reasons get four sentences. One string for four states would be +"I could not tell" spelled like an answer, and the reader opened that group to +find out which of the four happened. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/codev/hierarchy.test.ts | 62 +++++++ + apps/web/src/codev/hierarchy.ts | 64 ++++++- + apps/web/src/components/Sidebar.logic.test.ts | 172 ++++++++++++++++++ + apps/web/src/components/Sidebar.logic.ts | 116 ++++++++++++ + apps/web/src/components/Sidebar.tsx | 171 ++++++++++++++++- + 5 files changed, 574 insertions(+), 11 deletions(-) + +diff --git a/apps/web/src/codev/hierarchy.test.ts b/apps/web/src/codev/hierarchy.test.ts +index 593839f25..2b19c7c55 100644 +--- a/apps/web/src/codev/hierarchy.test.ts ++++ b/apps/web/src/codev/hierarchy.test.ts +@@ -257,6 +257,68 @@ describe("codev hierarchy: orphans are named, not dropped and not re-parented", + }); + }); + ++/** ++ * The section boundary, which is the sidebar's and not the tree's. ++ * ++ * The sidebar splits a project into Pinned / Active / Snoozed / Settled before ++ * any of this runs, so the tree is built over ONE of those lists. A builder whose ++ * architect the user pinned would then be looking at a list its parent is not in ++ * — and `parent-missing` would be a lie told to someone who can see the architect ++ * three rows above. `alsoVisible` is how the caller declares the rest. ++ */ ++describe("codev hierarchy: a parent in another section of the sidebar", () => { ++ it("says the architect is elsewhere, not that it is missing", () => { ++ const pinnedArchitect = architect("arch-pinned"); ++ const result = buildCodevHierarchy([builder("b-1", "arch-pinned")], { ++ alsoVisible: [pinnedArchitect], ++ }); ++ const orphan = onlyOrphan(result); ++ expect(orphan.reason).toBe("parent-elsewhere"); ++ expect(orphan.parentThreadId).toBe("arch-pinned"); ++ }); ++ ++ it("still says missing when the parent is in no section at all", () => { ++ const result = buildCodevHierarchy([builder("b-1", "arch-gone")], { ++ alsoVisible: [architect("arch-other")], ++ }); ++ expect(onlyOrphan(result).reason).toBe("parent-missing"); ++ }); ++ ++ /** ++ * Role outranks section. A non-architect parent is `parent-not-architect` ++ * wherever it sits — letting a section boundary change what a thread IS would ++ * make the reason a fact about the sidebar rather than about the data. ++ */ ++ it("keeps a non-architect parent wrong rather than merely elsewhere", () => { ++ const result = buildCodevHierarchy([builder("b-1", "not-an-architect")], { ++ alsoVisible: [plain("not-an-architect")], ++ }); ++ expect(onlyOrphan(result).reason).toBe("parent-not-architect"); ++ }); ++ ++ it("does not return the threads it was only shown", () => { ++ const result = buildCodevHierarchy([builder("b-1", "arch-pinned")], { ++ alsoVisible: [architect("arch-pinned")], ++ }); ++ const returned = [ ++ ...result.architects.flatMap((subtree) => [subtree.architect, ...subtree.builders]), ++ ...result.unmanaged, ++ ...result.orphaned.map((orphan) => orphan.thread), ++ ].map((thread) => thread.id); ++ // Rendering them here would render them twice: they already have a row in ++ // the section the caller is drawing them in. ++ expect(returned).toEqual(["b-1"]); ++ }); ++ ++ it("nests normally when the architect is in the same list, shown or not", () => { ++ const result = buildCodevHierarchy([architect("arch-1"), builder("b-1", "arch-1")], { ++ alsoVisible: [architect("arch-1")], ++ }); ++ expect(at(result.architects, 0).builders.map((b) => b.id)).toEqual(["b-1"]); ++ expect(result.orphaned).toEqual([]); ++ }); ++}); ++ + describe("codev hierarchy: everything is accounted for", () => { + it("places every thread exactly once across a mixed project", () => { + accountsForEveryThread([ +diff --git a/apps/web/src/codev/hierarchy.ts b/apps/web/src/codev/hierarchy.ts +index b8319156e..8cccea6c5 100644 +--- a/apps/web/src/codev/hierarchy.ts ++++ b/apps/web/src/codev/hierarchy.ts +@@ -66,12 +66,18 @@ export interface CodevHierarchyFields { + * Why a builder could not be placed under an architect. + * + * `parent-missing` is the ordinary one: the architect is archived, deleted, or +- * simply not in this project's slice. `parent-not-architect` and `no-parent` are +- * states phase 3 refuses at write time, so they cannot arise from a server that +- * accepted the thread — they are carried anyway, because a renderer that assumes +- * an impossible state is impossible has no way to show one when it happens. ++ * simply not in this project's slice. `parent-elsewhere` is the one that keeps ++ * the other three honest — see `alsoVisible` below. `parent-not-architect` and ++ * `no-parent` are states phase 3 refuses at write time, so they cannot arise ++ * from a server that accepted the thread — they are carried anyway, because a ++ * renderer that assumes an impossible state is impossible has no way to show one ++ * when it happens. + */ +-export type OrphanReason = "parent-missing" | "parent-not-architect" | "no-parent"; ++export type OrphanReason = ++ | "parent-missing" ++ | "parent-elsewhere" ++ | "parent-not-architect" ++ | "no-parent"; + + export interface OrphanedBuilder { + readonly thread: T; +@@ -91,21 +97,50 @@ export interface CodevThreadHierarchy { + readonly orphaned: readonly OrphanedBuilder[]; + } + ++/** ++ * Threads the caller can see but is not grouping. ++ * ++ * The sidebar splits a project into Pinned, Active, Snoozed and Settled before ++ * anything here runs, and the tree is built over ONE of those lists. So a builder ++ * whose architect the user has pinned would be looking at a list its parent is ++ * not in — and `parent-missing` would then be a lie told to a human who can see ++ * the architect three rows above. ++ * ++ * `alsoVisible` is the rest of the sidebar. A parent found there is reported as ++ * `parent-elsewhere`, which is the difference between "your architect is gone" ++ * and "your architect is in another section". Leaving it out is safe and is what ++ * the unit tests do; leaving it out at the CALL SITE is the bug it exists to ++ * prevent. ++ */ ++export interface CodevHierarchyContext { ++ readonly alsoVisible?: readonly T[] | undefined; ++} ++ + /** + * Group a flat list of threads into the three-level tree. + * + * Every input thread appears exactly once in the result. That is the property the + * tests are written around: a renderer that drops a thread loses a running agent +- * from the only place a human looks for one. ++ * from the only place a human looks for one. Threads passed as `alsoVisible` are ++ * NOT in the result — they belong to whichever list the caller is rendering them ++ * in, and returning them here would render them twice. + */ + export function buildCodevHierarchy( + threads: readonly T[], ++ context: CodevHierarchyContext = {}, + ): CodevThreadHierarchy { + const architectIds = new Set(); + for (const thread of threads) { + if (thread.role === "architect") architectIds.add(thread.id); + } + const present = new Set(threads.map((thread) => thread.id)); ++ // Role, not just presence: an architect the user pinned is `parent-elsewhere`, ++ // but a NON-architect parent stays `parent-not-architect` wherever it sits. ++ // Collapsing the two would let a section boundary change what a thread is. ++ const elsewhereRoleById = new Map(); ++ for (const thread of context.alsoVisible ?? []) { ++ if (!present.has(thread.id)) elsewhereRoleById.set(thread.id, thread.role); ++ } + + const buildersByArchitect = new Map(); + const unmanaged: T[] = []; +@@ -128,7 +163,22 @@ export function buildCodevHierarchy( + continue; + } + if (!present.has(parentThreadId)) { +- orphaned.push({ thread, reason: "parent-missing", parentThreadId }); ++ // `has`, not `get() !== undefined`. A roleless thread's role IS ++ // `undefined`, so reading the value cannot tell "not in another section" ++ // from "in another section, with no role" — and the second is exactly the ++ // `parent-not-architect` case this branch exists to name. ++ if (!elsewhereRoleById.has(parentThreadId)) { ++ orphaned.push({ thread, reason: "parent-missing", parentThreadId }); ++ continue; ++ } ++ orphaned.push({ ++ thread, ++ reason: ++ elsewhereRoleById.get(parentThreadId) === "architect" ++ ? "parent-elsewhere" ++ : "parent-not-architect", ++ parentThreadId, ++ }); + continue; + } + if (!architectIds.has(parentThreadId)) { +diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts +index ba75f2eaa..b4bd5d443 100644 +--- a/apps/web/src/components/Sidebar.logic.test.ts ++++ b/apps/web/src/components/Sidebar.logic.test.ts +@@ -4,7 +4,9 @@ import { + animatePinnedLayoutChanges, + archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, ++ buildCodevSidebarOrder, + buildMultiSelectThreadContextMenuItems, ++ describeCodevOrphanReason, + createThreadJumpHintVisibilityController, + getSidebarThreadIdsToPrewarm, + getVisibleSidebarThreadIds, +@@ -1683,3 +1685,173 @@ describe("sortLogicalProjectsForSidebar", () => { + ).toEqual(["logical-newer", "logical-older"]); + }); + }); ++ ++/** ++ * Codev customization (spec 250), phase 7 — the sidebar's order, tested over the ++ * sidebar's OWN thread type. ++ * ++ * `makeThread` builds a real `Thread`, not a row type invented for this file. ++ * The grouping's unit tests deliberately use their own minimal row (that is what ++ * makes them about grouping), and the cost of that is they cannot tell you the ++ * module fits anything the sidebar actually holds. These can, and that is the ++ * only reason they are here rather than there. ++ */ ++describe("buildCodevSidebarOrder", () => { ++ const architectThread = (id: string, title = id) => ++ makeThread({ id: ThreadId.make(id), title, role: "architect" }); ++ const builderThread = (id: string, parent: string | null, title = id) => ++ makeThread({ ++ id: ThreadId.make(id), ++ title, ++ role: "builder", ++ parentThreadId: parent === null ? null : ThreadId.make(parent), ++ }); ++ const plainThread = (id: string, title = id) => makeThread({ id: ThreadId.make(id), title }); ++ ++ it("leaves a project with no Codev roles exactly as it found it", () => { ++ const threads = [plainThread("a"), plainThread("b"), plainThread("c")]; ++ const order = buildCodevSidebarOrder(threads); ++ expect(order.hasHierarchy).toBe(false); ++ expect(order.entries.map((entry) => entry.kind)).toEqual([ ++ "unmanaged", ++ "unmanaged", ++ "unmanaged", ++ ]); ++ // Same threads, same order. The renderer's no-hierarchy branch draws this ++ // list directly, so "unchanged for upstream" is this assertion. ++ expect(order.entries.map((entry) => entry.thread)).toEqual(threads); ++ }); ++ ++ it("puts each architect above the builders that name it", () => { ++ const order = buildCodevSidebarOrder([ ++ builderThread("b-2", "arch-1"), ++ architectThread("arch-1"), ++ builderThread("b-1", "arch-1"), ++ ]); ++ expect(order.hasHierarchy).toBe(true); ++ expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ ["architect", "arch-1"], ++ // Input order within the subtree, because the caller already sorted. ++ ["builder", "b-2"], ++ ["builder", "b-1"], ++ ]); ++ }); ++ ++ it("renders two architects as two subtrees, each owning its own builders", () => { ++ const order = buildCodevSidebarOrder([ ++ architectThread("arch-a"), ++ architectThread("arch-b"), ++ builderThread("a-1", "arch-a"), ++ builderThread("b-1", "arch-b"), ++ builderThread("b-2", "arch-b"), ++ ]); ++ expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ ["architect", "arch-a"], ++ ["builder", "a-1"], ++ ["architect", "arch-b"], ++ ["builder", "b-1"], ++ ["builder", "b-2"], ++ ]); ++ const [first] = order.entries; ++ expect(first?.kind === "architect" ? first.builderCount : null).toBe(1); ++ }); ++ ++ it("keeps an architect with no builders as its own subtree", () => { ++ const order = buildCodevSidebarOrder([architectThread("arch-empty")]); ++ const [only] = order.entries; ++ expect(only?.kind).toBe("architect"); ++ expect(only?.kind === "architect" ? only.builderCount : null).toBe(0); ++ }); ++ ++ it("puts roleless threads below the tree, claimed by nothing in it", () => { ++ const order = buildCodevSidebarOrder([ ++ plainThread("upstream-1"), ++ architectThread("arch-1"), ++ builderThread("b-1", "arch-1"), ++ plainThread("upstream-2"), ++ ]); ++ expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ ["architect", "arch-1"], ++ ["builder", "b-1"], ++ ["unmanaged", "upstream-1"], ++ ["unmanaged", "upstream-2"], ++ ]); ++ }); ++ ++ /** ++ * Orphans last. They are what a human reads when something has gone wrong, and ++ * above the tree they would make every ordinary sidebar open with its ++ * exceptions first. ++ */ ++ it("puts orphans last, each carrying the reason and the parent it named", () => { ++ const order = buildCodevSidebarOrder([ ++ builderThread("lost", "arch-gone"), ++ architectThread("arch-1"), ++ builderThread("b-1", "arch-1"), ++ plainThread("upstream-1"), ++ ]); ++ expect(order.entries.map((entry) => entry.kind)).toEqual([ ++ "architect", ++ "builder", ++ "unmanaged", ++ "orphan", ++ ]); ++ const orphan = order.entries.at(-1); ++ expect(orphan?.kind === "orphan" ? orphan.reason : null).toBe("parent-missing"); ++ expect(orphan?.kind === "orphan" ? orphan.parentThreadId : null).toBe("arch-gone"); ++ }); ++ ++ /** ++ * THE SECTION BOUNDARY, at the call site's shape. ++ * ++ * The sidebar builds this over ONE of Pinned / Active / Snoozed / Settled. A ++ * builder whose architect is pinned is therefore looking at a list its parent ++ * is not in, and "its architect is not in this project" would be a lie told to ++ * someone who can see the architect three rows above. ++ */ ++ it("distinguishes an architect in another sidebar section from a missing one", () => { ++ const pinnedArchitect = architectThread("arch-pinned"); ++ const order = buildCodevSidebarOrder( ++ [builderThread("b-1", "arch-pinned"), builderThread("lost", "arch-gone")], ++ [pinnedArchitect], ++ ); ++ const reasons = order.entries.map((entry) => (entry.kind === "orphan" ? entry.reason : null)); ++ expect(reasons).toEqual(["parent-elsewhere", "parent-missing"]); ++ // The pinned architect keeps its row in the pinned block and gains none ++ // here: two rows for one thread is worse than the problem it solves. ++ expect(order.entries.map((entry) => entry.thread.id)).toEqual(["b-1", "lost"]); ++ }); ++ ++ it("accounts for every thread exactly once", () => { ++ const threads = [ ++ plainThread("upstream-1"), ++ architectThread("arch-a"), ++ builderThread("a-1", "arch-a"), ++ architectThread("arch-b"), ++ builderThread("b-1", "arch-b"), ++ builderThread("lost", "arch-gone"), ++ builderThread("solo", null), ++ architectThread("arch-empty"), ++ ]; ++ const order = buildCodevSidebarOrder(threads); ++ expect(order.entries.map((entry) => entry.thread.id).toSorted()).toEqual( ++ threads.map((thread) => thread.id).toSorted(), ++ ); ++ }); ++}); ++ ++describe("describeCodevOrphanReason", () => { ++ /** ++ * Four reasons, four sentences, no repeats. ++ * ++ * One string for four states would be "I could not tell" spelled like an ++ * answer — the reader opened this group to find out WHICH thing happened. ++ */ ++ it("gives every reason its own words", () => { ++ const sentences = ( ++ ["parent-missing", "parent-elsewhere", "parent-not-architect", "no-parent"] as const ++ ).map(describeCodevOrphanReason); ++ expect(new Set(sentences).size).toBe(4); ++ expect(sentences.every((sentence) => sentence.length > 0)).toBe(true); ++ }); ++}); +diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts +index 8067fdd59..db8ef1a56 100644 +--- a/apps/web/src/components/Sidebar.logic.ts ++++ b/apps/web/src/components/Sidebar.logic.ts +@@ -13,6 +13,12 @@ import type { ThreadRouteTarget } from "../threadRoutes"; + import { cn } from "../lib/utils"; + import { isLatestTurnSettled } from "../session-logic"; + import { resolveServerBackedAppStageLabel } from "../branding.logic"; ++import { ++ buildCodevHierarchy, ++ hasCodevHierarchy, ++ type CodevHierarchyFields, ++ type OrphanReason, ++} from "../codev/hierarchy"; + + export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; + export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; +@@ -959,3 +965,113 @@ export function sortScopedProjectsForSidebar< + left.id.localeCompare(right.id), + ); + } ++ ++/** ++ * Codev customization (spec 250), phase 7 — the sidebar's Workspace > Architect > ++ * Builders order, as a function. ++ * ++ * `buildCodevHierarchy` answers "what is the tree"; this answers "in what order ++ * does the sidebar draw it, and what does each row need to know about its place". ++ * The component applies what this returns and decides nothing itself, so the ++ * ordering claims are settled here rather than through a rendered DOM. ++ * ++ * ## Why the ORDER is returned, and not just the groups ++ * ++ * The sidebar's ordered thread list is not only a render order. It is also the ++ * range shift-click selects over and the sequence jump-hint labels are assigned ++ * from (`orderedThreads` / `jumpLabelByKey`). A component that reordered rows ++ * while leaving that list alone would draw a tree whose shift-select ran in the ++ * old order — every row in the right place and the keyboard reaching the wrong ++ * ones, which no screenshot shows. ++ * ++ * So the entries come back in one order, the caller renders in it AND derives ++ * the ordered list from it, and the two cannot drift. ++ */ ++export type CodevSidebarEntry = ++ | { readonly kind: "architect"; readonly thread: T; readonly builderCount: number } ++ | { readonly kind: "builder"; readonly thread: T; readonly architectId: string } ++ | { readonly kind: "unmanaged"; readonly thread: T } ++ | { ++ readonly kind: "orphan"; ++ readonly thread: T; ++ readonly reason: OrphanReason; ++ readonly parentThreadId: string | null; ++ }; ++ ++export interface CodevSidebarOrder { ++ /** ++ * False when nothing in the list carries a Codev role. ++ * ++ * The caller renders exactly what it rendered before spec 250 in that case, ++ * and `entries` is the untouched input as `unmanaged`. Every upstream user's ++ * sidebar has to be unchanged for a feature they do not have — an empty tree's ++ * chrome is still new furniture. ++ */ ++ readonly hasHierarchy: boolean; ++ readonly entries: readonly CodevSidebarEntry[]; ++} ++ ++/** ++ * Order one section's threads into architects, their builders, the untouched ++ * flat list, and then the orphans. ++ * ++ * Orphans go LAST on purpose. They are the section a human reads when something ++ * has gone wrong, and putting them above the working tree would make every ++ * ordinary sidebar open with its exceptions first. ++ */ ++export function buildCodevSidebarOrder( ++ threads: readonly T[], ++ alsoVisible: readonly T[] = [], ++): CodevSidebarOrder { ++ if (!hasCodevHierarchy(threads)) { ++ return { ++ hasHierarchy: false, ++ entries: threads.map((thread) => ({ kind: "unmanaged", thread }) as const), ++ }; ++ } ++ const hierarchy = buildCodevHierarchy(threads, { alsoVisible }); ++ const entries: CodevSidebarEntry[] = []; ++ for (const subtree of hierarchy.architects) { ++ entries.push({ ++ kind: "architect", ++ thread: subtree.architect, ++ builderCount: subtree.builders.length, ++ }); ++ for (const builder of subtree.builders) { ++ entries.push({ kind: "builder", thread: builder, architectId: subtree.architect.id }); ++ } ++ } ++ for (const thread of hierarchy.unmanaged) { ++ entries.push({ kind: "unmanaged", thread }); ++ } ++ for (const orphan of hierarchy.orphaned) { ++ entries.push({ ++ kind: "orphan", ++ thread: orphan.thread, ++ reason: orphan.reason, ++ parentThreadId: orphan.parentThreadId, ++ }); ++ } ++ return { hasHierarchy: true, entries }; ++} ++ ++/** ++ * What the orphan group says about one row, in words a human can act on. ++ * ++ * Each reason gets its OWN sentence. A single "could not be placed" would spend ++ * the group's whole purpose: the reader is there to find out which of four ++ * different things happened, and one string for four states is the shape of ++ * "I could not tell" spelled like an answer. ++ */ ++export function describeCodevOrphanReason(reason: OrphanReason): string { ++ switch (reason) { ++ case "parent-missing": ++ return "its architect is not in this project"; ++ case "parent-elsewhere": ++ return "its architect is pinned, snoozed, or settled"; ++ case "parent-not-architect": ++ return "the thread it names as parent is not an architect"; ++ case "no-parent": ++ return "it names no architect"; ++ } ++} +diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx +index 63681a548..f066f59b8 100644 +--- a/apps/web/src/components/Sidebar.tsx ++++ b/apps/web/src/components/Sidebar.tsx +@@ -125,6 +125,9 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; + import { + animatePinnedLayoutChanges, + buildBulkTitleRegenerationContextMenuItem, ++ buildCodevSidebarOrder, ++ describeCodevOrphanReason, ++ type CodevSidebarEntry, + formatWorkingDurationLabel, + firstValidTimestampMs, + hasUnseenCompletion, +@@ -2240,9 +2243,39 @@ export default function Sidebar() { + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + ++ /** ++ * Codev customization (spec 250), phase 7 — the active section's order. ++ * ++ * `alsoVisible` is the rest of the sidebar, and it is not optional in spirit: ++ * without it a builder whose architect the user PINNED reports its parent as ++ * missing, three rows below the architect the user can see. The whole grouping ++ * runs over one section, so the other sections have to be declared. ++ */ ++ const codevActiveOrder = useMemo( ++ () => ++ buildCodevSidebarOrder(activeThreads, [ ++ ...pinnedThreads, ++ ...snoozedThreads, ++ ...settledThreads, ++ ]), ++ [activeThreads, pinnedThreads, snoozedThreads, settledThreads], ++ ); ++ // The tree's order IS the ordered list. Shift-range-select and jump-hint ++ // labels are assigned from `orderedThreads`, so a reordered render with the ++ // old list behind it puts every row in the right place and the keyboard on ++ // the wrong ones. ++ const orderedActiveThreads = useMemo( ++ () => codevActiveOrder.entries.map((entry) => entry.thread), ++ [codevActiveOrder], ++ ); + const orderedThreads = useMemo( +- () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], +- [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], ++ () => [ ++ ...pinnedThreads, ++ ...orderedActiveThreads, ++ ...visibleSnoozedThreads, ++ ...renderedSettledThreads, ++ ], ++ [pinnedThreads, orderedActiveThreads, visibleSnoozedThreads, renderedSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => +@@ -3832,8 +3865,138 @@ export default function Sidebar() { + />, + ); + } +- for (const thread of activeThreads) { +- items.push(renderThreadRow(thread, "active")); ++ // Codev customization (spec 250), phase 7 — Workspace > ++ // Architect > Builders. ++ // ++ // `hasHierarchy` is false for every project that carries no ++ // Codev role, and this branch is then the loop it has always ++ // been: same rows, same order, no wrappers, no headings. An ++ // upstream sidebar must not grow furniture for a feature it ++ // does not have. ++ if (!codevActiveOrder.hasHierarchy) { ++ for (const entry of codevActiveOrder.entries) { ++ items.push(renderThreadRow(entry.thread, "active")); ++ } ++ } else { ++ const entries = codevActiveOrder.entries; ++ const codevRowKey = (thread: EnvironmentThreadShell) => ++ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); ++ let renderedArchitectCount = 0; ++ const unmanaged: EnvironmentThreadShell[] = []; ++ const orphans: Extract< ++ CodevSidebarEntry, ++ { kind: "orphan" } ++ >[] = []; ++ for (let index = 0; index < entries.length; index += 1) { ++ const entry = entries[index]; ++ if (entry === undefined) continue; ++ if (entry.kind === "unmanaged") { ++ unmanaged.push(entry.thread); ++ continue; ++ } ++ if (entry.kind === "orphan") { ++ orphans.push(entry); ++ continue; ++ } ++ // Builders were consumed by their architect below. ++ if (entry.kind === "builder") continue; ++ renderedArchitectCount += 1; ++ const builders: EnvironmentThreadShell[] = []; ++ for (let next = index + 1; next < entries.length; next += 1) { ++ const following = entries[next]; ++ if (following === undefined || following.kind !== "builder") break; ++ builders.push(following.thread); ++ } ++ items.push( ++
  • ++
      ++ {renderThreadRow(entry.thread, "active")} ++ {builders.length > 0 ? ( ++
    • ++ {/* The rail is the nesting. Builder rows are the ++ same cards as every other row — an indent and ++ a hairline say "owned by the row above" ++ without inventing a second row design that ++ would have to be kept in step with the ++ first. */} ++
        ++ {builders.map((builder) => ++ renderThreadRow(builder, "active"), ++ )} ++
      ++
    • ++ ) : null} ++
    ++
  • , ++ ); ++ } ++ // Threads Codev did not create keep the flat presentation ++ // they have always had, below the tree and claimed by ++ // nothing in it. The divider is the whole separation: a ++ // heading over them would name a group that is really just ++ // "everything else". ++ if (unmanaged.length > 0 && renderedArchitectCount > 0) { ++ items.push( ++
  • , ++ ); ++ } ++ for (const thread of unmanaged) { ++ items.push(renderThreadRow(thread, "active")); ++ } ++ if (orphans.length > 0) { ++ items.push( ++
  • ++
    ++ ++ Unattributed builders ({orphans.length}) ++ ++ ++
    ++
  • , ++ ); ++ for (const orphan of orphans) { ++ items.push( ++
  • ++
      ++ {renderThreadRow(orphan.thread, "active")} ++
    ++ {/* The reason, per row, in words. Four states share ++ this group and a reader is here to find out which ++ one happened; one sentence for all four would be ++ "I could not tell" spelled like an answer. */} ++

    ++ Orphaned: {describeCodevOrphanReason(orphan.reason)} ++

    ++
  • , ++ ); ++ } ++ } + } + // Snoozed shelf: between the inbox and Settled — out of the + // way, never gone. The header always renders while anything diff --git a/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch b/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch new file mode 100644 index 000000000..7eb770334 --- /dev/null +++ b/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch @@ -0,0 +1,448 @@ +From a183f56ecec2c039fb0b2b33a2ea75b7316bce76 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Sun, 30 Aug 2026 23:50:54 -0600 +Subject: [PATCH 16/34] [Spec 250][Phase: phase_7] feat: the project level, the + role marker, and an orphan group that is not a warning +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Three changes from the architect's review of the first screenshots. One of them +is a criterion gap; two are the tree saying out loud what it had been leaving to +the test data. + +**The project level was missing, and criterion 1 asks for three.** Project, +architect, that architect's builders — the render had two. The project was +present only as a caption repeated on all eight cards: one string, eight times, +in the most prominent line of every row, with the thing a human actually scans +for sitting below it in lighter weight. It is a heading now, once, carrying the +project's own favicon so it reads as t3code's project rather than as a new kind +of shelf. Rows under it drop the per-row label; rows NOT under it keep it, where +it is the only thing saying which project they belong to. + +Architect subtrees are gathered by project so a project's run is contiguous — a +heading over a run another project interrupts is a heading that lies. Projects +keep the order their first architect arrived in; architects keep their order +within a project. The key is passed IN rather than read off a field, because +`projectId` alone is not a project: two environments can carry the same id, and +the sidebar already scopes every project lookup by `environmentId:projectId`. +Without a key there is no project level at all and `startsProject` is false — +a heading the caller cannot title would render as the word "Project" above +every tree. + +**Nothing said which row was an architect.** It was conveyed by one level of +subtle indent plus test data that happened to be called "Architect beta" and +"Builder alpha one". Real threads are called `builder/spir-250`, and at that +point the indent is the entire signal. The role takes the same slot as the +project label — the slot answering "what is this row, before you read its name", +and empty on a row under a project heading. Builders get no label: they are the +rows indented under one, and a caption on every child of a labelled parent is a +caption nobody reads. + +**The orphan group was amber, and amber says something is broken.** An archived +architect orphaning its builders is a state we ruled LEGAL. It takes Settled's +treatment now — a non-default state that is not a fault, which is exactly what +this is — with the emphasis moved to the count, because the count is the part +worth noticing. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/components/Sidebar.logic.test.ts | 85 +++++++++++++- + apps/web/src/components/Sidebar.logic.ts | 79 +++++++++++-- + apps/web/src/components/Sidebar.tsx | 106 +++++++++++++++--- + 3 files changed, 243 insertions(+), 27 deletions(-) + +diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts +index b4bd5d443..cb8531674 100644 +--- a/apps/web/src/components/Sidebar.logic.test.ts ++++ b/apps/web/src/components/Sidebar.logic.test.ts +@@ -1813,7 +1813,7 @@ describe("buildCodevSidebarOrder", () => { + const pinnedArchitect = architectThread("arch-pinned"); + const order = buildCodevSidebarOrder( + [builderThread("b-1", "arch-pinned"), builderThread("lost", "arch-gone")], +- [pinnedArchitect], ++ { alsoVisible: [pinnedArchitect] }, + ); + const reasons = order.entries.map((entry) => (entry.kind === "orphan" ? entry.reason : null)); + expect(reasons).toEqual(["parent-elsewhere", "parent-missing"]); +@@ -1855,3 +1855,86 @@ describe("describeCodevOrphanReason", () => { + expect(sentences.every((sentence) => sentence.length > 0)).toBe(true); + }); + }); ++ ++/** ++ * The PROJECT level, which is the first of criterion 1's three. ++ * ++ * A key is passed in rather than read off a field because `projectId` alone is ++ * not a project: two environments can carry the same id, and the sidebar already ++ * scopes every project lookup by `environmentId:projectId`. ++ */ ++describe("buildCodevSidebarOrder: the project level", () => { ++ const inProject = (projectId: string, thread: Thread): Thread => ({ ++ ...thread, ++ projectId: ProjectId.make(projectId), ++ }); ++ const architectIn = (projectId: string, id: string) => ++ inProject(projectId, makeThread({ id: ThreadId.make(id), title: id, role: "architect" })); ++ const builderIn = (projectId: string, id: string, parent: string) => ++ inProject( ++ projectId, ++ makeThread({ ++ id: ThreadId.make(id), ++ title: id, ++ role: "builder", ++ parentThreadId: ThreadId.make(parent), ++ }), ++ ); ++ const projectKeyOf = (thread: Thread) => `${thread.environmentId}:${thread.projectId}`; ++ ++ it("marks the first architect of each project and no others", () => { ++ const order = buildCodevSidebarOrder( ++ [ ++ architectIn("project-a", "arch-a1"), ++ architectIn("project-b", "arch-b1"), ++ architectIn("project-a", "arch-a2"), ++ ], ++ { projectKeyOf }, ++ ); ++ expect( ++ order.entries.map((entry) => ++ entry.kind === "architect" ? [entry.thread.id, entry.startsProject] : null, ++ ), ++ ).toEqual([ ++ ["arch-a1", true], ++ // Contiguous: a project's subtrees are gathered so a heading never covers ++ // a run that another project interrupts. ++ ["arch-a2", false], ++ ["arch-b1", true], ++ ]); ++ }); ++ ++ it("keeps each architect's builders with it across the regrouping", () => { ++ const order = buildCodevSidebarOrder( ++ [ ++ architectIn("project-a", "arch-a1"), ++ builderIn("project-a", "a1-one", "arch-a1"), ++ architectIn("project-b", "arch-b1"), ++ builderIn("project-b", "b1-one", "arch-b1"), ++ architectIn("project-a", "arch-a2"), ++ builderIn("project-a", "a2-one", "arch-a2"), ++ ], ++ { projectKeyOf }, ++ ); ++ expect(order.entries.map((entry) => entry.thread.id)).toEqual([ ++ "arch-a1", ++ "a1-one", ++ "arch-a2", ++ "a2-one", ++ "arch-b1", ++ "b1-one", ++ ]); ++ }); ++ ++ /** ++ * Without a key there is no project level, and `startsProject` is false rather ++ * than true-for-the-first. A heading the caller cannot title is worse than no ++ * heading: it would render as the word "Project" above every tree. ++ */ ++ it("claims no project level when the caller did not say how to key one", () => { ++ const order = buildCodevSidebarOrder([architectIn("project-a", "arch-a1")]); ++ const [only] = order.entries; ++ expect(only?.kind === "architect" ? only.startsProject : null).toBe(false); ++ expect(only?.kind === "architect" ? only.projectKey : "unset").toBeNull(); ++ }); ++}); +diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts +index db8ef1a56..ef4e26ea2 100644 +--- a/apps/web/src/components/Sidebar.logic.ts ++++ b/apps/web/src/components/Sidebar.logic.ts +@@ -988,7 +988,25 @@ export function sortScopedProjectsForSidebar< + * the ordered list from it, and the two cannot drift. + */ + export type CodevSidebarEntry = +- | { readonly kind: "architect"; readonly thread: T; readonly builderCount: number } ++ | { ++ readonly kind: "architect"; ++ readonly thread: T; ++ readonly builderCount: number; ++ /** ++ * The project this subtree belongs to, and whether it opens that project. ++ * ++ * Criterion 1 is three levels — project, architect, that architect's ++ * builders — and the project level is a HEADING, not a label repeated on ++ * every row. Eight rows each captioned with the same project name spend ++ * the most prominent line of every card on one string, and push the thing ++ * a human is scanning for into the line below it. ++ * ++ * `null` when the caller did not say how to key projects, which is what ++ * the grouping's own unit tests do. ++ */ ++ readonly projectKey: string | null; ++ readonly startsProject: boolean; ++ } + | { readonly kind: "builder"; readonly thread: T; readonly architectId: string } + | { readonly kind: "unmanaged"; readonly thread: T } + | { +@@ -998,6 +1016,20 @@ export type CodevSidebarEntry = + readonly parentThreadId: string | null; + }; + ++export interface CodevSidebarOrderOptions { ++ /** The rest of the sidebar — see `CodevHierarchyContext`. */ ++ readonly alsoVisible?: readonly T[] | undefined; ++ /** ++ * A stable key per project. Supplying it groups architect subtrees by project ++ * and marks the first subtree of each, so the caller can draw the heading. ++ * ++ * Passed as a function rather than read off a field because `projectId` alone ++ * is not a project: two environments can carry the same id, and the sidebar ++ * already scopes everything by `environmentId:projectId`. ++ */ ++ readonly projectKeyOf?: ((thread: T) => string) | undefined; ++} ++ + export interface CodevSidebarOrder { + /** + * False when nothing in the list carries a Codev role. +@@ -1021,7 +1053,7 @@ export interface CodevSidebarOrder { + */ + export function buildCodevSidebarOrder( + threads: readonly T[], +- alsoVisible: readonly T[] = [], ++ options: CodevSidebarOrderOptions = {}, + ): CodevSidebarOrder { + if (!hasCodevHierarchy(threads)) { + return { +@@ -1029,16 +1061,41 @@ export function buildCodevSidebarOrder( + entries: threads.map((thread) => ({ kind: "unmanaged", thread }) as const), + }; + } +- const hierarchy = buildCodevHierarchy(threads, { alsoVisible }); +- const entries: CodevSidebarEntry[] = []; ++ const hierarchy = buildCodevHierarchy(threads, { alsoVisible: options.alsoVisible }); ++ const projectKeyOf = options.projectKeyOf; ++ // Architects are re-ordered so a project's subtrees are contiguous, because a ++ // heading over a run that another project interrupts is a heading that lies. ++ // Projects keep the order their FIRST architect arrived in, and architects ++ // keep their order within a project — the caller's sort still decides both. ++ const architectsByProject = new Map(); ++ const projectOrder: string[] = []; + for (const subtree of hierarchy.architects) { +- entries.push({ +- kind: "architect", +- thread: subtree.architect, +- builderCount: subtree.builders.length, +- }); +- for (const builder of subtree.builders) { +- entries.push({ kind: "builder", thread: builder, architectId: subtree.architect.id }); ++ const key = projectKeyOf === undefined ? "" : projectKeyOf(subtree.architect); ++ const existing = architectsByProject.get(key); ++ if (existing === undefined) { ++ projectOrder.push(key); ++ architectsByProject.set(key, [subtree]); ++ } else { ++ architectsByProject.set(key, [...existing, subtree]); ++ } ++ } ++ ++ const entries: CodevSidebarEntry[] = []; ++ for (const projectKey of projectOrder) { ++ const subtrees = architectsByProject.get(projectKey) ?? []; ++ let first = true; ++ for (const subtree of subtrees) { ++ entries.push({ ++ kind: "architect", ++ thread: subtree.architect, ++ builderCount: subtree.builders.length, ++ projectKey: projectKeyOf === undefined ? null : projectKey, ++ startsProject: projectKeyOf !== undefined && first, ++ }); ++ first = false; ++ for (const builder of subtree.builders) { ++ entries.push({ kind: "builder", thread: builder, architectId: subtree.architect.id }); ++ } + } + } + for (const thread of hierarchy.unmanaged) { +diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx +index f066f59b8..3c56f1e34 100644 +--- a/apps/web/src/components/Sidebar.tsx ++++ b/apps/web/src/components/Sidebar.tsx +@@ -729,6 +729,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { + projectCwd: string | null; + projectFaviconPath: string | null; + projectTitle: string | null; ++ /** ++ * Codev customization (spec 250). The row's place in the hierarchy, in words. ++ * ++ * It takes the same slot as `projectTitle` because it answers the same ++ * question that slot exists to answer — "what is this row, before you read its ++ * name" — and because a row under a project HEADING has no project label to ++ * show there. Without it the tree is one level of indent plus whatever the ++ * threads happen to be called, and real threads are called `builder/spir-250`. ++ */ ++ codevRoleLabel?: string | null | undefined; + providerEntryByInstanceId: ReadonlyMap; + timestampFormat: TimestampFormat; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; +@@ -1423,14 +1433,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { + faviconPath={props.projectFaviconPath} + className="size-4 shrink-0" + /> +- {props.projectTitle ? ( ++ {props.projectTitle ?? props.codevRoleLabel ? ( + +- {props.projectTitle} ++ {props.projectTitle ?? props.codevRoleLabel} + + ) : ( + +@@ -2253,11 +2263,13 @@ export default function Sidebar() { + */ + const codevActiveOrder = useMemo( + () => +- buildCodevSidebarOrder(activeThreads, [ +- ...pinnedThreads, +- ...snoozedThreads, +- ...settledThreads, +- ]), ++ buildCodevSidebarOrder(activeThreads, { ++ alsoVisible: [...pinnedThreads, ...snoozedThreads, ...settledThreads], ++ // `environmentId:projectId`, the key the sidebar already uses for every ++ // project lookup. `projectId` alone is not a project: two environments ++ // can carry the same id. ++ projectKeyOf: (thread) => `${thread.environmentId}:${thread.projectId}`, ++ }), + [activeThreads, pinnedThreads, snoozedThreads, settledThreads], + ); + // The tree's order IS the ordered list. Shift-range-select and jump-hint +@@ -3695,6 +3707,13 @@ export default function Sidebar() { + thread: EnvironmentThreadShell, + section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, ++ // Codev customization (spec 250). Rows drawn under a project ++ // HEADING drop their per-row project label — it is the same ++ // string as the heading two lines above, repeated once per ++ // row in the most prominent position on the card. Rows NOT ++ // under a heading keep it; there it is the only thing saying ++ // which project they belong to. ++ codev?: { readonly underProjectHeading?: boolean; readonly roleLabel?: string }, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), +@@ -3769,10 +3788,13 @@ export default function Sidebar() { + ) ?? null + } + projectTitle={ +- projectDisplayNameByKey.get( +- `${thread.environmentId}:${thread.projectId}`, +- ) ?? null ++ codev?.underProjectHeading === true ++ ? null ++ : (projectDisplayNameByKey.get( ++ `${thread.environmentId}:${thread.projectId}`, ++ ) ?? null) + } ++ codevRoleLabel={codev?.roleLabel ?? null} + providerEntryByInstanceId={ + providerEntriesByEnvironment.get(thread.environmentId) ?? + EMPTY_PROVIDER_ENTRIES +@@ -3907,6 +3929,40 @@ export default function Sidebar() { + if (following === undefined || following.kind !== "builder") break; + builders.push(following.thread); + } ++ // The PROJECT level, which is the first of criterion 1's ++ // three. It replaces the project name repeated on every ++ // card below it: one heading, once, in the place a heading ++ // goes — carrying the project's own favicon so it reads as ++ // t3code's project rather than as a new kind of shelf. ++ if (entry.startsProject) { ++ // `as const` keeps the template-literal type the ++ // project maps are keyed by; a widened `string` does not ++ // index them. ++ const projectKey = ++ `${entry.thread.environmentId}:${entry.thread.projectId}` as const; ++ items.push( ++
  • ++
    ++ ++ ++ {projectDisplayNameByKey.get(projectKey) ?? "Project"} ++ ++ ++
    ++
  • , ++ ); ++ } + items.push( +
  • +
      +- {renderThreadRow(entry.thread, "active")} ++ {renderThreadRow(entry.thread, "active", undefined, { ++ underProjectHeading: entry.projectKey !== null, ++ // The role, said out loud. One level of indent plus ++ // a thread called `builder/spir-250` is not a tree ++ // anybody can read; the test data that happens to ++ // be named "Architect beta" was doing this job. ++ roleLabel: "Architect", ++ })} + {builders.length > 0 ? ( +
    • + {/* The rail is the nesting. Builder rows are the +@@ -3931,8 +3994,14 @@ export default function Sidebar() { + data-testid="sidebar-codev-builders" + className="ml-2.5 flex flex-col gap-px border-l border-sidebar-border/60 pl-1.5" + > ++ {/* No role label on builders: they are the ++ rows indented under one, and a caption on ++ every child of a labelled parent is a ++ caption nobody reads. */} + {builders.map((builder) => +- renderThreadRow(builder, "active"), ++ renderThreadRow(builder, "active", undefined, { ++ underProjectHeading: entry.projectKey !== null, ++ }), + )} +
    +
  • +@@ -3968,10 +4037,17 @@ export default function Sidebar() { + className="list-none" + > +
    +- +- Unattributed builders ({orphans.length}) ++ {/* Settled's treatment, not a warning's. An ++ archived architect orphaning its builders is a ++ LEGAL state we ruled on deliberately, and amber ++ says something is broken. The count carries the ++ emphasis, because the count is the part worth ++ noticing. */} ++ ++ Unattributed builders{" "} ++ ({orphans.length}) + +- ++ +
    + , + ); diff --git a/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch b/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch new file mode 100644 index 000000000..82ee6057e --- /dev/null +++ b/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch @@ -0,0 +1,57 @@ +From 7c7096d49de9f7aef8dfd0fc6f97aa81ec33ddac Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Mon, 31 Aug 2026 00:10:31 -0600 +Subject: [PATCH 17/34] [Spec 250][Phase: phase_7] fix: the builder count came + from the render, so it could only agree with it +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Review finding. `data-codev-builder-count` was fed by the render-side run scan +that gathers a subtree's builder rows — the same scan the Playwright assertion +counts. Two derivations of one fact, and the attribute could only ever agree with +the thing it was there to check. + +It comes from `entry.builderCount` now, which the GROUPING decided. The rows +beneath it are still what was DRAWN, so the DOM cross-checks logic against render +and the test asserting a count of 3 beside three builder rows is a real check +rather than a tautology. + +Also parenthesised `(props.projectTitle ?? props.codevRoleLabel)`. `??` does bind +tighter and the expression was correct; a reader who has to check a precedence +table to know whether a line is a bug is paying a cost the parentheses save. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/components/Sidebar.tsx | 11 +++++++++-- + 1 file changed, 9 insertions(+), 2 deletions(-) + +diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx +index 3c56f1e34..fa1224068 100644 +--- a/apps/web/src/components/Sidebar.tsx ++++ b/apps/web/src/components/Sidebar.tsx +@@ -1433,7 +1433,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { + faviconPath={props.projectFaviconPath} + className="size-4 shrink-0" + /> +- {props.projectTitle ?? props.codevRoleLabel ? ( ++ {(props.projectTitle ?? props.codevRoleLabel) ? ( + +
      diff --git a/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch b/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch new file mode 100644 index 000000000..147ed9d31 --- /dev/null +++ b/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch @@ -0,0 +1,910 @@ +From 5e8ace3b186f3452a82da0272ceb3ec157f02a62 Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Mon, 31 Aug 2026 00:32:32 -0600 +Subject: [PATCH 18/34] [Spec 250][Phase: phase_8] feat: a porch gate says + which gate, and what it is asking +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Spec 146 wrote the gate name into the thread TITLE because t3code had nowhere +else to put it. Phase 4 gave it somewhere. This reads it. + +**Three states, and the third is the one that gets lost.** `porch gate ` +without `--request-file` is legitimate and common, so a gate can be pending with +no question and no choices. Rendering that as "no gate" hides a human who is +waiting; rendering it as a question with nothing under it reads as a broken gate +rather than an absent request. It is its own state and says so in words: +"Gate pending, no structured request." + +**It is not folded into session status.** `starting` / `running` / `ready` / +`settled` describe what the AGENT is doing and none of them can say "a human has +to decide". `hasPendingApprovals` cannot stand in either — that is provider TOOL +approvals, and the contract already says why the two must stay apart. So the +sidebar marker is its own element, from its own derivation, in a hue none of the +existing pills own: amber is Pending Approval, indigo Awaiting Input, sky +Working, violet Plan Ready, emerald Completed, and reusing amber would collapse +exactly the distinction the gate block was built to make. + +The marker sits OUTSIDE the status slot, which fades to make room for the row's +hover actions. A gate that vanished when someone reached for the row would be +missing precisely when it was being acted on. + +**No `dangerouslySetInnerHTML`, and it is not a style choice.** The question, +the labels, the consequences and the terminal excerpt are written by a builder +agent into `status.yaml` and carried over a socket, so a panel that rendered them +as markup would let a repository under review script the page reviewing it. React +escapes text children; a test reads this file and fails on any use of the escape +hatch, because the escaping test alone gets QUIETER as that defect gets worse — +one field silently stops matching. + +The panel is above the composer and not inside `ComposerBannerStack`: that stack +shows one banner at a time with the rest behind a cap, so a gate in it would hide +the other banners or be hidden by them — and a gate is not dismissible. + +Every fixture in the tests is DECODED through `CodevGate` rather than written as +a literal, so six choices, two recommendations and a multi-line question fail in +the fixture instead of being asserted about as if they could arrive. One case the +plan asked for turns out to be unrepresentable: `consequence` is required, so a +choice without one is refused whole. Recorded as a test rather than dropped. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/codev/GatePanel.test.tsx | 175 +++++++++++++++++ + apps/web/src/codev/GatePanel.tsx | 140 +++++++++++++ + apps/web/src/codev/gateState.test.ts | 271 ++++++++++++++++++++++++++ + apps/web/src/codev/gateState.ts | 143 ++++++++++++++ + apps/web/src/components/ChatView.tsx | 17 ++ + apps/web/src/components/Sidebar.tsx | 26 +++ + 6 files changed, 772 insertions(+) + create mode 100644 apps/web/src/codev/GatePanel.test.tsx + create mode 100644 apps/web/src/codev/GatePanel.tsx + create mode 100644 apps/web/src/codev/gateState.test.ts + create mode 100644 apps/web/src/codev/gateState.ts + +diff --git a/apps/web/src/codev/GatePanel.test.tsx b/apps/web/src/codev/GatePanel.test.tsx +new file mode 100644 +index 000000000..62933c0fb +--- /dev/null ++++ b/apps/web/src/codev/GatePanel.test.tsx +@@ -0,0 +1,175 @@ ++/** ++ * Codev customization (spec 250), phase 8 — the panel, rendered. ++ * ++ * `gateState.test.ts` settles what the three states ARE. This settles what a ++ * human sees for each, and one claim it makes is a security claim: gate text is ++ * written by a builder agent into `status.yaml` and carried over a socket, so if ++ * any of it reached the page as markup, a repository under review could script ++ * the page reviewing it. ++ */ ++ ++import { readFileSync } from "node:fs"; ++import { join } from "node:path"; ++ ++import { CodevGate } from "@t3tools/contracts"; ++import * as Schema from "effect/Schema"; ++import { renderToStaticMarkup } from "react-dom/server"; ++import { describe, expect, it } from "vite-plus/test"; ++ ++import { GatePanel } from "./GatePanel"; ++ ++const decodeGate = Schema.decodeUnknownSync(CodevGate); ++ ++const render = (overrides: Record | null) => ++ renderToStaticMarkup( ++ , ++ ); ++ ++describe("GatePanel", () => { ++ it("renders nothing when there is no gate", () => { ++ // Every thread that is not blocked has to look exactly as it did before ++ // spec 250; an empty frame above the composer is new furniture. ++ expect(render(null)).toBe(""); ++ expect(renderToStaticMarkup()).toBe(""); ++ }); ++ ++ it("names the gate and when it was requested, from the gate block", () => { ++ const markup = render({ question: "Approve the plan?" }); ++ expect(markup).toContain("plan-approval"); ++ expect(markup).toContain('data-codev-gate-name="plan-approval"'); ++ // The timestamp is machine-readable as well as rendered: a relative label ++ // alone ("2 hours ago") drifts with the clock and cannot be checked against ++ // anything. Asserted as `