From dca379156847a322a01ebc072269c3897a3ed49d Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:11:57 -0400 Subject: [PATCH 1/9] fix(cli): cotal mint reuses the existing identity unless --force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mint.ts unconditionally called newIdentity() on every mint, so re-minting an agent (e.g. to refresh its channels from the persona file) rotated the mesh id. The durable ACL row and the dm/dlv delivery durables are all keyed by the nkey public key, so rotation orphaned them → the agent went @mention-wake-blind with 'consumer not found' until re-provisioned (hit live 6x during a fleet relaunch). Option B (maintainer ruling on design PR #3, cubic P1): re-mint reuses the same identity. New core helper identityFromCreds(creds): Identity — the id-preserving sibling of idFromCreds (which it reuses for the id + JWT-subject cross-check), returning { id, seed } from the creds' seed block. mint computes the out path before minting; if a creds file exists there and --force is not passed, it re-signs that SAME id+seed with fresh ACLs; otherwise it mints a new identity. --force keeps the rotation escape hatch (compromised key / deliberate new id). Orthogonal to the still-open mint-strategy fork (#3): changes WHICH id mint uses, not WHERE the ACL write triggers. Pairs with the PR #4 DLV-durable fix. Refs #3. Co-Authored-By: seal --- implementations/cli/src/commands/mint.ts | 19 ++++++++++++++----- packages/core/src/identity.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index 474e51d4c7..ca56809830 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -1,8 +1,9 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve, join, dirname } from "node:path"; import { parseArgs } from "node:util"; import { agentFilePath, + identityFromCreds, loadAgentFile, mintCreds, mkSecretDir, @@ -28,7 +29,7 @@ export async function mint(argv: string[]): Promise { profile: { type: "string" }, out: { type: "string" }, signer: { type: "boolean" }, // emit a stripped signer file instead of agent/observer creds - force: { type: "boolean" }, // (--signer) overwrite an existing signer file + force: { type: "boolean" }, // overwrite an existing --signer file; or (agent) rotate to a fresh id instead of reusing the existing creds' "allow-subscribe": { type: "string" }, // read ACL override (comma-separated) "allow-publish": { type: "string" }, // post ACL override (comma-separated) }, @@ -85,12 +86,20 @@ export async function mint(argv: string[]): Promise { allowPublish = splitList(values["allow-publish"]) ?? def?.allowPublish; role = def?.role; } - const identity = newIdentity(); - const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); + // Re-mint reuses the SAME identity by default. mint's read/post ACLs come from the persona file, + // so re-minting is how an agent's channels get refreshed — but the mesh id, its durable ACL row, + // and its dm/dlv durables are all keyed by the nkey public key. Rotating the id on every mint + // (the old behavior) orphaned that row + those durables, leaving the agent @mention-wake-blind + // until re-provisioned. So: if a creds file already exists here, re-sign its SAME id+seed with the + // fresh ACLs; only mint a brand-new identity when there's no creds yet, or --force rotates + // deliberately (a compromised key / intentional new identity). const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`)); + const reuse = !values.force && existsSync(out); + const identity = reuse ? identityFromCreds(readFileSync(out, "utf8")) : newIdentity(); + const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); mkSecretDir(dirname(out)); writeSecretFile(out, creds); console.log(c.green(`✓ minted ${profile} creds for "${name}"`)); - console.log(c.dim(` id: ${identity.id}`)); + console.log(c.dim(` id: ${identity.id}${reuse ? " (reused — re-mint kept the identity)" : " (new)"}`)); console.log(c.dim(` creds: ${out}`)); } diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index 86e16ffda8..14b2b895d9 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -42,3 +42,15 @@ export function idFromCreds(creds: string): string { if (sub && sub !== id) throw new Error(`creds: seed identity ${id} != JWT subject ${sub}`); return id; } + +/** The full identity (id + seed) carried by a creds file — the id-preserving sibling of + * {@link idFromCreds}. Re-mint reads this to RE-SIGN the SAME identity (stable id) with refreshed + * ACLs instead of rotating to a fresh nkey, so the agent's durable ACL row and dm/dlv durables (all + * keyed by id) stay valid across a re-mint. `idFromCreds` supplies the id AND its JWT-subject + * cross-check (a spliced seed+JWT throws there); the seed is the same block it validates, returned + * raw for {@link mintCreds} to re-embed. */ +export function identityFromCreds(creds: string): Identity { + const seedM = creds.match(/BEGIN USER NKEY SEED-----\s*([\s\S]*?)\s*------END USER NKEY SEED/); + if (!seedM) throw new Error("creds: no user nkey seed block found"); + return { id: idFromCreds(creds), seed: seedM[1].trim() }; +} From 4161ddd7cf3477b0e19e0930cba6c88090d3b47b Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:31:23 -0400 Subject: [PATCH 2/9] fix(cli): gate mint id-reuse to the agent profile + fail loud on unparseable creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the reuse logic (cubic P1 + greptile P2 on #7): - Reuse now requires profile === "agent". The durable-ACL-orphan rationale is agent-specific (persona-refresh workflow + dm/dlv durables keyed to the id); observer/admin creds have neither, and silently preserving a privileged admin key across re-mints would extend its lifetime unexpectedly. Observer/admin now always rotate, as before. - A present-but-unparseable creds file (empty, truncated, not a user creds file) now fails loud with an actionable error naming --force, instead of letting identityFromCreds throw a raw parse exception. Silently rotating there would orphan the durable row the existing id may still own, so fresh-mint is not a safe fallback — the operator must opt in via --force or remove the stale file. Verified: agent re-mint keeps id; admin/observer re-mint rotate; corrupt creds at the out path errors naming --force (no raw crash, no silent rotate). Refs #3. Co-Authored-By: seal --- implementations/cli/src/commands/mint.ts | 37 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index ca56809830..6c6292bbd8 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -10,6 +10,7 @@ import { newIdentity, stripSpaceAuth, writeSecretFile, + type Identity, type Profile, } from "@cotal-ai/core"; import { authDir, loadSpaceAuth } from "@cotal-ai/workspace"; @@ -86,16 +87,34 @@ export async function mint(argv: string[]): Promise { allowPublish = splitList(values["allow-publish"]) ?? def?.allowPublish; role = def?.role; } - // Re-mint reuses the SAME identity by default. mint's read/post ACLs come from the persona file, - // so re-minting is how an agent's channels get refreshed — but the mesh id, its durable ACL row, - // and its dm/dlv durables are all keyed by the nkey public key. Rotating the id on every mint - // (the old behavior) orphaned that row + those durables, leaving the agent @mention-wake-blind - // until re-provisioned. So: if a creds file already exists here, re-sign its SAME id+seed with the - // fresh ACLs; only mint a brand-new identity when there's no creds yet, or --force rotates - // deliberately (a compromised key / intentional new identity). + // Re-mint reuses the SAME identity by default — but only for the `agent` profile. mint's read/post + // ACLs come from the persona file, so re-minting is how an agent's channels get refreshed; and the + // mesh id, its durable ACL row, and its dm/dlv durables are all keyed by the nkey public key, so + // rotating the id on every mint (the old behavior) orphaned that row + those durables, leaving the + // agent @mention-wake-blind until re-provisioned. Observer/admin creds carry no persona-refresh + // workflow and no durable footprint to orphan, and silently extending a privileged admin key's + // lifetime across re-mints would be surprising — so they always rotate. Reuse only when: agent + // profile, a creds file already exists here, and --force did not ask for deliberate rotation + // (a compromised key / intentional new identity). const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`)); - const reuse = !values.force && existsSync(out); - const identity = reuse ? identityFromCreds(readFileSync(out, "utf8")) : newIdentity(); + const reuse = profile === "agent" && !values.force && existsSync(out); + let identity: Identity; + if (reuse) { + try { + identity = identityFromCreds(readFileSync(out, "utf8")); + } catch (e) { + // A present-but-unreadable creds file (empty, truncated, or not a user creds file) must fail + // loud, not silently rotate — silently minting a fresh id here would orphan the durable row + // the existing id may still own. Point the operator at the deliberate-rotation escape hatch. + throw new Error( + `cotal mint: creds already exist at ${out} but could not be parsed to reuse the identity ` + + `(${e instanceof Error ? e.message : String(e)}). Pass --force to mint a fresh identity ` + + `(rotates the id), or remove the file if it is stale.`, + ); + } + } else { + identity = newIdentity(); + } const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); mkSecretDir(dirname(out)); writeSecretFile(out, creds); From bd3edb6d420cac961be55dd60f9abc9b3b45c6aa Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:49:55 -0400 Subject: [PATCH 3/9] test(cli): red-green regression for mint identity-reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 — packages/core/smoke/identity.smoke.ts (offline, 4 asserts): identityFromCreds round-trips {id, seed} unchanged; agrees with idFromCreds (one-id-everywhere); rejects spliced creds (seed vs foreign JWT subject) and a missing seed block. Layer 2 — implementations/cli/smoke/mint-reuse.smoke.ts (hermetic, exercises the real mint() against a tmp .cotal root, 8 checks): re-minting an agent reuses the SAME id; --force rotates; a persona ACL change refreshes the baked sub.allow WITHOUT rotating the id; first mint of a new name mints fresh (absent-creds path, no crash); observer + admin re-mint rotate (reuse is agent-only); an unparseable existing creds file fails loud naming --force (no silent rotate, no raw crash). Red-green verified: reverting the reuse block to unconditional newIdentity() fails checks 1, 3, and 6 (two mints → two ids; the exact #3 cubic-P1 durable-orphan bug); the fix turns them green. Registered both as smoke:identity + smoke:mint-reuse and wired into smoke:ci. Refs #3. Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 134 ++++++++++++++++++ package.json | 4 +- packages/core/smoke/identity.smoke.ts | 51 +++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 implementations/cli/smoke/mint-reuse.smoke.ts create mode 100644 packages/core/smoke/identity.smoke.ts diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts new file mode 100644 index 0000000000..d6beb985eb --- /dev/null +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -0,0 +1,134 @@ +/** + * `cotal mint` identity-reuse smoke (hermetic — no broker). Exercises the REAL mint() command against + * a tmp `.cotal/` root laid out exactly as `cotal up` writes it (auth.json via saveSpaceAuth + a + * persona file), and asserts the reuse-unless-force contract end to end. Run with: + * pnpm --filter @cotal-ai/cli exec tsx smoke/mint-reuse.smoke.ts + * + * The load-bearing regression: re-minting an AGENT keeps the SAME nkey id (so its durable ACL row + + * dm/dlv durables, all id-keyed, stay valid) — the old unconditional newIdentity() rotated the id on + * every mint and orphaned them, leaving the agent @mention-wake-blind. --force rotates deliberately; + * a persona ACL change refreshes the baked channels WITHOUT rotating; observer/admin always rotate + * (reuse is agent-only — no durable footprint to orphan, and re-signing a privileged key would + * silently extend its lifetime); and a present-but-unparseable creds file fails loud (never a silent + * fresh-mint that would orphan the id its predecessor may still own). + * + * mint() resolves its root via findCotalRoot() walking up from process.cwd(), so the harness chdir's + * into the tmp root and restores cwd in the finally. + */ +import { strict as assert } from "node:assert"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createSpaceAuth, idFromCreds } from "@cotal-ai/core"; +import { authDir, saveSpaceAuth } from "@cotal-ai/workspace"; +import { mint } from "../src/commands/mint.js"; + +let failures = 0; +function check(label: string, cond: boolean, extra?: unknown): void { + console.log(`${cond ? "✓" : "✗"} ${label}${cond ? "" : ` — ${JSON.stringify(extra)}`}`); + if (!cond) failures++; +} + +// The chat-read channels a minted creds file grants (decode the JWT's nats.sub.allow, keep chat.*. +// entries). Same JWT-decode shape as manager/smoke/persona-identity-acl.smoke.ts. +function credSubChat(path: string): string[] { + const jwt = readFileSync(path, "utf8").split("\n").find((l) => l && !l.startsWith("-") && l.split(".").length === 3)!; + const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8")); + const allow: string[] = claims.nats?.sub?.allow ?? []; + return allow.filter((s) => s.includes(".chat.")); +} + +// mint() logs its result to stdout; silence it (restored even on throw) so the check output stays +// legible — the ids we assert on are read straight from the written creds file, not from the log. +async function mintQuiet(argv: string[]): Promise { + const orig = console.log; + console.log = () => {}; + try { + await mint(argv); + } finally { + console.log = orig; + } +} + +// A tmp `.cotal/` root with real space trust material (auth.json, exactly as `cotal up` persists it) +// and a `scout` agent persona reading/posting the `general` channel. +const space = `mint-reuse-${randomUUID().slice(0, 8)}`; +const auth = await createSpaceAuth(space); +const root = mkdtempSync(join(tmpdir(), "cotal-mint-reuse-")); +const agentsDir = join(root, ".cotal", "agents"); +mkdirSync(agentsDir, { recursive: true }); +saveSpaceAuth(authDir(root), auth); +const scoutPersona = join(agentsDir, "scout.md"); +writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general]\nallowPublish: [general]\n---\nbody\n"); +const scoutCreds = join(authDir(root), "creds", "scout.creds"); +const credsDir = join(authDir(root), "creds"); + +const prevCwd = process.cwd(); +process.chdir(root); // findCotalRoot() walks up from cwd — anchor it at our tmp root +try { + // 1) THE regression: re-minting an agent twice reuses the SAME id (before the fix, two mints gave + // two different ids and orphaned the id-keyed durables). + await mintQuiet(["scout"]); + const id1 = idFromCreds(readFileSync(scoutCreds, "utf8")); + await mintQuiet(["scout"]); + const id2 = idFromCreds(readFileSync(scoutCreds, "utf8")); + check("re-mint of an agent reuses the same id", id1 === id2, { id1, id2 }); + + // 2) --force is the deliberate-rotation escape hatch: a fresh id despite an existing creds file. + await mintQuiet(["scout", "--force"]); + const id3 = idFromCreds(readFileSync(scoutCreds, "utf8")); + check("mint --force rotates to a different id", id3 !== id2, { id2, id3 }); + + // 3) A persona ACL change is applied on re-mint (refreshed channels) WITHOUT rotating the id — + // the whole point: an agent's read scope is refreshed while its mesh id stays stable. + const beforeChans = credSubChat(scoutCreds); // channels baked before the persona change + writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general, review]\nallowPublish: [general]\n---\nbody\n"); + await mintQuiet(["scout"]); + const id4 = idFromCreds(readFileSync(scoutCreds, "utf8")); + const afterChans = credSubChat(scoutCreds); + check("re-mint after an ACL change keeps the identity", id4 === id3, { id3, id4 }); + check( + "re-mint bakes the NEW channel into sub.allow (review added, was absent before)", + afterChans.some((s) => s.endsWith(".chat.*.review")) && !beforeChans.some((s) => s.endsWith(".chat.*.review")), + { beforeChans, afterChans }, + ); + + // 4) First-ever mint of a brand-new name has no creds file — the reuse=false path must mint a fresh + // identity and write valid creds, not crash on the absent-file read. + writeFileSync(join(agentsDir, "newbie.md"), "---\nname: newbie\nsubscribe: [general]\nallowSubscribe: [general]\n---\nbody\n"); + const newbieCreds = join(credsDir, "newbie.creds"); + await mintQuiet(["newbie"]); + const idNew = idFromCreds(readFileSync(newbieCreds, "utf8")); + check("first mint of a brand-new name mints a fresh identity (absent-creds path, no crash)", idNew !== id4 && idNew.startsWith("U"), { idNew, id4 }); + + // 5) Reuse is AGENT-ONLY: re-minting an observer or admin creds file ROTATES the id (a privileged + // key must not silently extend its lifetime across re-mints). + for (const profile of ["observer", "admin"] as const) { + const pOut = join(credsDir, `${profile}-dash.creds`); + await mintQuiet([`${profile}-dash`, "--profile", profile]); + const a = idFromCreds(readFileSync(pOut, "utf8")); + await mintQuiet([`${profile}-dash`, "--profile", profile]); + const b = idFromCreds(readFileSync(pOut, "utf8")); + check(`re-mint of an ${profile} creds rotates the id (reuse is agent-only)`, a !== b, { profile, a, b }); + } + + // 6) A present-but-unparseable creds file at the out path fails LOUD naming --force — never a raw + // parse crash, and never a silent fresh-mint (which would orphan the predecessor id's durables). + mkdirSync(credsDir, { recursive: true }); + writeFileSync(join(credsDir, "corrupt.creds"), ""); // present but no seed block + let msg = ""; + try { + await mintQuiet(["corrupt"]); + } catch (e) { + msg = e instanceof Error ? e.message : String(e); + } + check("unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", /could not be parsed/.test(msg) && /--force/.test(msg), { msg }); +} finally { + process.chdir(prevCwd); + rmSync(root, { recursive: true, force: true }); +} + +console.log(`\nmint-reuse smoke: ${failures === 0 ? "OK ✅" : "FAILED ❌"} (${failures} failing)`); +assert.equal(failures, 0, `${failures} check(s) failed`); +process.exit(0); diff --git a/package.json b/package.json index 30071edc70..7d81818fad 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "gen:schema": "node scripts/generate-cotal-schema.mjs", "test": "pnpm -r --if-present test", "check": "pnpm typecheck && pnpm test && pnpm smoke:view && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect && pnpm smoke:core-boundary && pnpm smoke:preflight && pnpm smoke:ci", - "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl", + "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl && pnpm smoke:identity && pnpm smoke:mint-reuse", + "smoke:identity": "tsx packages/core/smoke/identity.smoke.ts", + "smoke:mint-reuse": "tsx implementations/cli/smoke/mint-reuse.smoke.ts", "clean:dry": "git clean -ndX -- node_modules .pnpm-store packages extensions implementations examples remotion", "clean": "git clean -fdX -- node_modules .pnpm-store packages extensions implementations examples remotion", "cotal": "tsx bin/cotal.ts", diff --git a/packages/core/smoke/identity.smoke.ts b/packages/core/smoke/identity.smoke.ts new file mode 100644 index 0000000000..7004e6acbd --- /dev/null +++ b/packages/core/smoke/identity.smoke.ts @@ -0,0 +1,51 @@ +/** + * identityFromCreds unit smoke (pure, no broker) — run with: + * pnpm --filter @cotal-ai/core exec tsx smoke/identity.smoke.ts + * + * Defends the id-preserving creds reader that `cotal mint` uses to REUSE an agent's identity across a + * re-mint (the reuse-unless-force fix). The contract: the {id, seed} carried by a creds file round-trips + * unchanged; the id agrees with idFromCreds; and a creds file that is corrupt (no seed block) or spliced + * (a seed paired with a foreign JWT subject) is REJECTED — never silently handing back a wrong or + * mismatched id, which is exactly what would let a re-mint re-sign the wrong identity. + */ +import assert from "node:assert/strict"; +import { createSpaceAuth, mintCreds } from "../src/provision.js"; +import { newIdentity, idFromCreds, identityFromCreds } from "../src/identity.js"; + +// Offline key material — createSpaceAuth mints an operator→account chain locally, no broker needed. +const auth = await createSpaceAuth("test"); + +// (a) Round-trip: a freshly-minted agent creds file yields back the SAME id AND seed (both fields). +// This is the load-bearing property — reuse re-signs THIS identity, so a wrong id or a mangled +// seed here would silently rotate the agent despite the "reuse" intent. +{ + const id = newIdentity(); + const creds = await mintCreds(auth, id, "agent", { allowSubscribe: ["general"] }); + const got = identityFromCreds(creds); + assert.equal(got.id, id.id, "identityFromCreds must recover the minted id"); + assert.equal(got.seed, id.seed, "identityFromCreds must recover the minted seed verbatim"); +} + +// (b) Single-id cross-consistency at the API boundary: the id it returns agrees with idFromCreds on +// the same creds (the "one id everywhere" invariant — the two readers must never diverge). +{ + const creds = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + assert.equal(identityFromCreds(creds).id, idFromCreds(creds)); +} + +// (c) Spliced creds (A's seed + B's JWT ⇒ JWT subject ≠ seed identity) is REJECTED. identityFromCreds +// inherits idFromCreds's JWT-subject cross-check, so it can't return a seed whose JWT claims a +// different identity — the guard against re-signing a seed that was paired with someone else's JWT. +{ + const a = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + const b = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + const jwtBlock = /-----BEGIN NATS USER JWT-----[\s\S]*?------END NATS USER JWT------/; + const bJwt = b.match(jwtBlock)![0]; + const spliced = a.replace(jwtBlock, bJwt); // A's seed block, B's JWT subject + assert.throws(() => identityFromCreds(spliced), /!= JWT subject/); +} + +// (d) A creds string with no seed block throws the documented error rather than returning a bogus id. +assert.throws(() => identityFromCreds("this is not a creds file"), /no user nkey seed block found/); + +console.log("identity.smoke: all assertions passed"); From 994e85ce92a1b3a0ab26a53c2f85021b5b1fee5f Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:55:50 -0400 Subject: [PATCH 4/9] test(cli): assert corrupt-creds mint leaves the file untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic P2 on #7: the corrupt-creds check only asserted the error message, so a future mint() refactor that wrote fresh creds *before* surfacing the parse error would still pass while silently rotating the id — the exact regression the test guards. Add a filesystem-state assertion: after the failed mint, the corrupt file must be byte-unchanged (still empty), proving no silent fresh-mint. Refs #3. Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts index d6beb985eb..b7052fb0d2 100644 --- a/implementations/cli/smoke/mint-reuse.smoke.ts +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -116,14 +116,27 @@ try { // 6) A present-but-unparseable creds file at the out path fails LOUD naming --force — never a raw // parse crash, and never a silent fresh-mint (which would orphan the predecessor id's durables). mkdirSync(credsDir, { recursive: true }); - writeFileSync(join(credsDir, "corrupt.creds"), ""); // present but no seed block + const corruptCreds = join(credsDir, "corrupt.creds"); + writeFileSync(corruptCreds, ""); // present but no seed block let msg = ""; try { await mintQuiet(["corrupt"]); } catch (e) { msg = e instanceof Error ? e.message : String(e); } - check("unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", /could not be parsed/.test(msg) && /--force/.test(msg), { msg }); + check( + "unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", + /could not be parsed/.test(msg) && /--force/.test(msg), + { msg }, + ); + // Assert the FILESYSTEM state, not just the message: the failed mint must not have written fresh + // creds over the corrupt file. A future refactor that mints-then-throws would still surface a + // parse error yet silently rotate the id — this catches that by proving the file is untouched. + check( + "failed corrupt-creds mint left the file untouched (no silent fresh-mint)", + readFileSync(corruptCreds, "utf8") === "", + { content: readFileSync(corruptCreds, "utf8").slice(0, 40) }, + ); } finally { process.chdir(prevCwd); rmSync(root, { recursive: true, force: true }); From b3d86f1d33d21315488417ec680970ae5c769bad Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Thu, 9 Jul 2026 22:31:24 -0400 Subject: [PATCH 5/9] fix(cli): mint reuses identity only from the canonical creds path Closes the greptile P1 on #9: `cotal mint --out ` reused that file's nkey id and re-signed it with 's ACLs, crossing the identity boundary (and clobbering the target). Creds identify an agent by nkey id, not by name, so the only name<->creds binding is the canonical `creds/.creds` path. Reuse is now canonical-path-only: a custom `--out` onto an existing creds file fails loud unless `--force` asks for the overwrite deliberately (which rotates to a fresh identity). Regression: mint-reuse smoke gains the cross-identity guard (red->green): refuse + leave the target byte-identical; --force overwrites fresh. Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 35 +++++++++++++++++++ implementations/cli/src/commands/mint.ts | 19 ++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts index b7052fb0d2..e5893e8645 100644 --- a/implementations/cli/smoke/mint-reuse.smoke.ts +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -137,6 +137,41 @@ try { readFileSync(corruptCreds, "utf8") === "", { content: readFileSync(corruptCreds, "utf8").slice(0, 40) }, ); + + // 7) Cross-identity --out guard (greptile P1). Creds carry the nkey id, NOT the agent name, so the + // only name↔identity binding is the canonical creds/.creds path. Reuse is therefore + // canonical-path-only: `cotal mint --out ` must never reuse (re-sign + // that file's id with 's ACLs) nor silently clobber it (overwrite its id, orphaning its + // durables). Without --force it fails loud; --force is the deliberate-overwrite escape hatch. + writeFileSync(join(agentsDir, "victim.md"), "---\nname: victim\nsubscribe: [general]\nallowSubscribe: [general]\n---\nbody\n"); + writeFileSync(join(agentsDir, "raider.md"), "---\nname: raider\nsubscribe: [general]\nallowSubscribe: [general, review, ops]\n---\nbody\n"); + const victimCreds = join(credsDir, "victim.creds"); + await mintQuiet(["victim"]); + const victimBefore = readFileSync(victimCreds, "utf8"); + const victimIdBefore = idFromCreds(victimBefore); + let raiderMsg = ""; + try { + await mintQuiet(["raider", "--out", victimCreds]); + } catch (e) { + raiderMsg = e instanceof Error ? e.message : String(e); + } + check( + "mint --out fails loud naming --force (no cross-identity reuse)", + /--force/.test(raiderMsg) && /belong/.test(raiderMsg), + { raiderMsg }, + ); + check( + "refused cross-identity mint left the target creds byte-identical (no re-sign of its id, no clobber)", + readFileSync(victimCreds, "utf8") === victimBefore && idFromCreds(readFileSync(victimCreds, "utf8")) === victimIdBefore, + { changed: readFileSync(victimCreds, "utf8") !== victimBefore }, + ); + // --force is the explicit escape hatch: it rotates to a FRESH identity and overwrites the target. + await mintQuiet(["raider", "--out", victimCreds, "--force"]); + check( + "mint --out --force overwrites with a fresh identity (escape hatch intact)", + idFromCreds(readFileSync(victimCreds, "utf8")) !== victimIdBefore, + { victimIdBefore, after: idFromCreds(readFileSync(victimCreds, "utf8")) }, + ); } finally { process.chdir(prevCwd); rmSync(root, { recursive: true, force: true }); diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index 6c6292bbd8..9eafb03b1d 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -96,8 +96,23 @@ export async function mint(argv: string[]): Promise { // lifetime across re-mints would be surprising — so they always rotate. Reuse only when: agent // profile, a creds file already exists here, and --force did not ask for deliberate rotation // (a compromised key / intentional new identity). - const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`)); - const reuse = profile === "agent" && !values.force && existsSync(out); + const canonicalOut = resolve(join(dir, "creds", `${name}.creds`)); + const out = resolve(values.out ?? canonicalOut); + // The canonical `creds/.creds` path is the ONLY binding between an agent name and a creds + // file: the file bakes an nkey id, not the name (`identity.ts`), so a creds file at a custom + // `--out` cannot be attributed to . A custom `--out` onto an EXISTING creds file must + // therefore never be silently reused (re-signing another agent's id with this name's ACLs) nor + // overwritten (rotating that id, orphaning its id-keyed ACL row + dm/dlv durables) — fail loud + // unless --force asks for the overwrite deliberately. Identity reuse is thus canonical-path-only. + if (!values.force && out !== canonicalOut && existsSync(out)) { + throw new Error( + `cotal mint: --out ${out} already holds a creds file that may not belong to "${name}" — creds ` + + `identify an agent by nkey id, not by name, so this file cannot be safely reused or ` + + `overwritten for "${name}". Pass --force to overwrite it with a fresh identity, or point ` + + `--out at a path that does not exist yet.`, + ); + } + const reuse = profile === "agent" && !values.force && out === canonicalOut && existsSync(out); let identity: Identity; if (reuse) { try { From b96e3eab64629b0b926e55ff10dbcbd9d59efbda Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Thu, 9 Jul 2026 23:06:44 -0400 Subject: [PATCH 6/9] fix(cli): reject a symlinked creds path in mint Closes the second greptile P1 on #9: `out === canonicalOut` is a string compare (resolve() normalizes the path but does not follow symlinks), so it never proved the canonical file belongs to . If creds/.creds was a symlink to another agent's creds, existsSync/readFileSync/writeSecretFile all followed it, so `cotal mint ` read the target's id and wrote 's ACLs back through the link, clobbering the pointed-to agent (--force rotated a fresh id straight through it). mint now lstat's the resolved out path (lstat does not follow the link) and refuses a symlink outright, canonical or custom, with or without --force. Regression: mint-reuse smoke gains the symlinked-canonical-path case (read-through refused, link target byte-identical, --force still refused). Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 45 ++++++++++++++++++- implementations/cli/src/commands/mint.ts | 21 ++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts index e5893e8645..7d25539cf9 100644 --- a/implementations/cli/smoke/mint-reuse.smoke.ts +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -17,7 +17,7 @@ */ import { strict as assert } from "node:assert"; import { randomUUID } from "node:crypto"; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createSpaceAuth, idFromCreds } from "@cotal-ai/core"; @@ -172,6 +172,49 @@ try { idFromCreds(readFileSync(victimCreds, "utf8")) !== victimIdBefore, { victimIdBefore, after: idFromCreds(readFileSync(victimCreds, "utf8")) }, ); + + // 8) Symlinked canonical creds path (greptile P1). `out === canonicalOut` is a STRING compare — it + // does not prove the file at that path belongs to . If creds/.creds is a symlink to + // another agent's creds, existsSync/readFileSync/writeSecretFile all FOLLOW it, so a plain + // `cotal mint ` would read the target's id and write 's ACLs back THROUGH the link, + // clobbering the pointed-to agent. A creds file must be a real file at its own path: mint refuses + // a symlinked out path (canonical or custom, with or without --force), leaving the target intact. + writeFileSync(join(agentsDir, "keeper.md"), "---\nname: keeper\nsubscribe: [general]\nallowSubscribe: [general]\n---\nbody\n"); + writeFileSync(join(agentsDir, "decoy.md"), "---\nname: decoy\nsubscribe: [general]\nallowSubscribe: [general, ops]\n---\nbody\n"); + const keeperCreds = join(credsDir, "keeper.creds"); + await mintQuiet(["keeper"]); + const keeperBefore = readFileSync(keeperCreds, "utf8"); + const keeperIdBefore = idFromCreds(keeperBefore); + symlinkSync(keeperCreds, join(credsDir, "decoy.creds")); // canonical decoy path → keeper's real creds + let symlinkMsg = ""; + try { + await mintQuiet(["decoy"]); // canonical path, no --out, no --force + } catch (e) { + symlinkMsg = e instanceof Error ? e.message : String(e); + } + check( + "mint whose canonical creds is a symlink fails loud (no read/write through the link)", + /symlink/.test(symlinkMsg), + { symlinkMsg }, + ); + check( + "refused symlink mint left the link target byte-identical (no clobber of the pointed-to agent)", + readFileSync(keeperCreds, "utf8") === keeperBefore && idFromCreds(readFileSync(keeperCreds, "utf8")) === keeperIdBefore, + { changed: readFileSync(keeperCreds, "utf8") !== keeperBefore }, + ); + // --force must NOT bypass the guard: rotating a fresh id straight through the link still clobbers the + // target. The escape hatch is to remove the link, never to write through it. + let symlinkForceMsg = ""; + try { + await mintQuiet(["decoy", "--force"]); + } catch (e) { + symlinkForceMsg = e instanceof Error ? e.message : String(e); + } + check( + "mint --force does NOT write through a symlinked creds path (still refuses, target intact)", + /symlink/.test(symlinkForceMsg) && readFileSync(keeperCreds, "utf8") === keeperBefore, + { symlinkForceMsg, changed: readFileSync(keeperCreds, "utf8") !== keeperBefore }, + ); } finally { process.chdir(prevCwd); rmSync(root, { recursive: true, force: true }); diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index 9eafb03b1d..7002707d17 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { resolve, join, dirname } from "node:path"; import { parseArgs } from "node:util"; import { @@ -98,6 +98,25 @@ export async function mint(argv: string[]): Promise { // (a compromised key / intentional new identity). const canonicalOut = resolve(join(dir, "creds", `${name}.creds`)); const out = resolve(values.out ?? canonicalOut); + // A creds file must be a REAL file at its own path. `out === canonicalOut` below is a string compare + // (resolve() normalizes the path but does NOT follow symlinks), so it cannot prove the file at the + // canonical path belongs to . If creds/.creds is a symlink, existsSync/readFileSync/ + // writeSecretFile all FOLLOW it — mint would read the link target's id and write 's ACLs back + // THROUGH the link, clobbering the pointed-to agent (and --force would rotate a fresh id straight + // through it). Refuse a symlinked out path outright — canonical or custom, with or without --force. + let outIsSymlink = false; + try { + outIsSymlink = lstatSync(out).isSymbolicLink(); // lstat does NOT follow the link (unlike existsSync) + } catch { + // ENOENT: nothing at `out`, not even a dangling link — not a symlink, leave false. + } + if (outIsSymlink) { + throw new Error( + `cotal mint: ${out} is a symlink — a creds file must be a real file at its own path, not a link ` + + `to another agent's creds (following it would read or overwrite the wrong identity). Remove the ` + + `symlink and mint to a real path.`, + ); + } // The canonical `creds/.creds` path is the ONLY binding between an agent name and a creds // file: the file bakes an nkey id, not the name (`identity.ts`), so a creds file at a custom // `--out` cannot be attributed to . A custom `--out` onto an EXISTING creds file must From fa1dc39ebde85efaea85ae3453797656d76509a2 Mon Sep 17 00:00:00 2001 From: seal Date: Fri, 7 Aug 2026 14:04:01 -0400 Subject: [PATCH 7/9] docs(platform): design the Cotal fork-sync + branch-model reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sealedsecurity/Cotal` has drifted too far to keep building on: `origin/main` (`d55c6adb`) is **815 commits behind** `upstream/main` (`8571c1bb`) with 7 sealed commits, and `origin/sealed-fork` (`0106d35c`) is **33 ahead / 723 behind**. This record designs the branch-model reset Matt decided: `main` becomes a clean upstream mirror, `sealed-fork` becomes the integration branch (new `main` + sealed changes reapplied via reviewed PRs). ## What this is A design record (the plan, not the implementation) at `docs/designs/platform/cotal-fork-sync.md`. Its merge freezes the contract executing agents read. The frozen intent — sync-then-reapply — is Matt's and is not relitigated here; the record designs the HOW: migration sequence, curated reapply inventory, coordination with in-flight branches, and per-feature verification. ## Approach Hard reset + curated cherry-pick reapply, human-gated at the two shared-branch moves (Matt force-moves `main` to upstream and resets `sealed-fork` to it; archive tags pin the old tips first so no history is lost). The ~40 sealed commits collapse into 4 reapply lanes (oh-my-pi connector, zellij runtime, KV-watch-leak port, mint identity-reuse port), each its own PR with its own build+smoke gate, plus an explicit drop list (`@cotal-ai/pi` superseded by upstream's parallel `extensions/pi`; `yaml` workarounds dead now that upstream declares `yaml ^2.9.0`; `upstream-cotal-reconnect-logger` subsumed by `fe6d3ec1`). ## Red-team applied Draft was adversarially reviewed (design-critic) and every load-bearing finding verified at source before folding. The decisive correction: the connector lane (T4) is **not** the cheap tree-copy it first looked — its `loop.ts` imports `InboxTurn` from `@cotal-ai/connector-core`, a seam upstream **deleted** and re-landed in `extensions/pi` with a different contract (tombstone ledger). T4 is a semantic port on par with the others. This is surfaced as **OQ-7** (resurrect the old seam vs migrate onto upstream pi's exported ledger — recommend the latter). ## Open Questions (Matt rules before freeze) 7 load-bearing forks, each with a stated assumption + recommendation: PR #13 disposition, reapply-set curation, connector reconciliation with zheng's parallel branch, main-move execution ownership, where sealed-only artifacts live across the reset, hard-reset-vs-rename-cutover, and the connector InboxTurn strategy (OQ-7). Spec-impact: none. This is a process/branch-model design record under `docs/designs/`, not a change to `docs/specs/`. Co-authored-by: Matt Wilkinson --- docs/designs/platform/cotal-fork-sync.md | 319 +++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/designs/platform/cotal-fork-sync.md diff --git a/docs/designs/platform/cotal-fork-sync.md b/docs/designs/platform/cotal-fork-sync.md new file mode 100644 index 0000000000..dfc9dd5970 --- /dev/null +++ b/docs/designs/platform/cotal-fork-sync.md @@ -0,0 +1,319 @@ +# Cotal fork-sync + branch-model reset + +Status: Draft + +## Problem / Intent + +`sealedsecurity/Cotal` is a fork of `Cotal-AI/Cotal` that has drifted too far to keep building on: +`origin/main` (`d55c6adb`) is **815 commits behind** `upstream/main` (`8571c1bb`) and carries 7 +sealed commits; `origin/sealed-fork` (`0106d35c`) is **33 ahead / 723 behind**. Matt has decided +(frozen intent — not relitigated here) to reset the branch model: `main` becomes a clean mirror of +upstream `main` with zero sealed commits; `sealed-fork` becomes the integration branch = new `main` ++ the still-needed sealed changes reapplied via reviewed PRs. This record designs the HOW: the +migration sequence, the curated reapply inventory, coordination with in-flight branches, and the +per-feature verification plan. + +## Approach + +**Hard reset + curated cherry-pick reapply, human-gated at the two shared-branch moves.** + +1. **Sync `main`** — a human (Matt) force-moves `main` to `upstream/main` (`8571c1bb`). The agent + push-guard forbids agents pushing `main`; this step is specified here and executed by hand. + Immediately before the move, the same hands push archive tags pinning the old tips + (`archive/pre-sync-main` → `d55c6adb`, `archive/pre-sync-sealed-fork` → `0106d35c`) so no sealed + history is ever unreachable (a raw tag push is not a `jj-vine submit`, so it stays inside the + human gate — T2 commands 0a/0b). +2. **Reset `sealed-fork`** — same human gate force-moves `sealed-fork` to the new `main`. This is a + hard reset, not a merge: a merge of 723 upstream commits into 33 sealed commits would produce a + conflict swamp with no review story, and the whole point of the new model is that `sealed-fork`'s + delta over `main` is exactly the set of reviewed reapply PRs (assumption; OQ-6). Owners of the + in-flight branches are notified before the move and rebase after it (coordination cost recorded in + the Plan). +3. **Reapply via PRs, one feature-group per PR** — the 33+7 sealed commits collapse into 4 reapply + lanes (oh-my-pi connector, zellij runtime, cotal-mint identity-reuse, KV-watch-leak fix) plus an + explicit **drop list** for work upstream has superseded. Each lane is a feature branch off the new + `sealed-fork`, cherry-picked/re-derived, submitted via `jj-vine submit`, review-looped, and merged + into `sealed-fork` — never pushed to it directly. **These are not uniformly cheap:** the connector + (T4) reapplies a tree byte-identical to `sealed-fork`'s, but its `loop.ts:1` import of `InboxTurn` + from `@cotal-ai/connector-core` dangles on the new base (upstream deleted that seam — see the + inventory + OQ-7), so T4 is an API-migration port on par with T6/T7, not a tree-copy. + +Curated inventory (verified against the clone this session): + +| Group | Commits (on `origin/sealed-fork` / `origin/main`) | Disposition | +|---|---|---| +| oh-my-pi connector | `55bc0c60` (feat) + 12 fix/test commits through `568c8175`, incl. `e2347788` (pi-coding-agent 16.3.12 + zod), `ae2de4e1` (connector-core InboxTurn), `f6f91657` (CI smokes), `cd091f1b` (`@cotal-ai/delivery` decl) | **Reapply as a semantic PORT (not a tree-copy) — see OQ-7.** Upstream has no `extensions/connector-oh-my-pi` (`git ls-tree upstream/main extensions/` — only cmux, connector-{claude-code,codex,core,hermes,opencode}, orca, pi, tmux), so the tree lands with no path collision, and its `src/` is byte-identical to PR #13's branch (`git diff --quiet origin/sealed-fork origin/upstream-cotal-181-omp-connector -- extensions/connector-oh-my-pi/` is clean over the whole extension). **But byte-identity only proves our two copies match each other, not that the tree composes with upstream.** It does not: `loop.ts:1` imports `InboxTurn`/`InboxSource` from `@cotal-ai/connector-core`, and upstream deleted `extensions/connector-core/src/inbox-turn.ts` (`git cat-file -e` fails) and has no `ackInbox` (it drains via `drainInboxIds`, agent.ts:462, with a two-site ack model). Upstream re-landed `InboxTurn` inside `extensions/pi` with a **different contract** (tombstone ledger, `TOMBSTONE_CAP=4096`, `extensions/pi/src/inbox-turn.ts:14,21`), exported at `extensions/pi/src/index.ts:4` "for SDK embedders driving their own session". `pnpm build` fails at `loop.ts:1` until the connector is ported to one of OQ-7's two strategies. | +| `@cotal-ai/zellij` runtime + placement | `35c26085`, `48c084b8` (feats) + 10 hardening commits through `a6ecda6b` | **Reapply.** Upstream has no `extensions/zellij` and no zellij references in `implementations/cli/src/` (grep clean); upstream's `extensions/tmux` is the sibling pattern to re-anchor the CLI-allowlist commits (`f2d0ba27`) against. | +| cotal-mint identity-reuse | The 7 commits on `origin/main` only (`dca37915`..`d55c6adb`, PR #9) — **not** on `sealed-fork` (`merge-base --is-ancestor d55c6adb origin/sealed-fork` fails) | **Reapply, expect conflicts.** Upstream `implementations/cli/src/commands/mint.ts` is 115 lines vs our 158 and has no identity-reuse (`grep 'reuse'` clean); its `--force` overwrite guard (`mint.ts:37-38`) sits inside a **new `--signer` mode** (`values.signer`, `mint.ts:30-45`) that did not exist in our version — the re-derivation must preserve it. Source from the archive tag, not the `cotal-mint-reuse-identity` branch (that branch carries only 4 of the 7 commits — see T7). | +| KV-watch-leak fix (SEA-1821) | `5e9a274f` + review rounds `b99340c5`, `c21270d8` (PR #12) | **Reapply, re-derive.** Upstream `packages/core/src/endpoint.ts` (3666 lines) has **no** `presenceWatch`/`channelWatch`/`stopWatch` (grep clean); the sealed fix lives at `origin/sealed-fork:packages/core/src/endpoint.ts:234,379-382`. The file drifted heavily (reconnect machinery now at `endpoint.ts:375-391`), so this is a port, not a clean cherry-pick. The smoke `packages/core/smoke/reconnect-watch-leak.smoke.ts` ports with it. **This lane also absorbs `fe6d3ec1`'s `packages/core` half** (exponential reconnect backoff) so all `endpoint.ts` reconnect surgery lands in one lane (see T6). | +| reconnect-logger (`origin/upstream-cotal-reconnect-logger`, tip `0041740f`, unmerged — 1 ahead of `sealed-fork`) | Standalone upstream-shaped variant of `fe6d3ec1` (edge logging, injectable MeshAgent logger, exponential endpoint backoff) | **Drop — superseded.** Its content is subsumed by `fe6d3ec1`, folded into T6's `endpoint.ts` port. Listed here so it is neither lost nor left basing on orphaned history; freeze-noticed in T1 and cleaned up in T9. | +| `@cotal-ai/pi` (`89a57ed5`) | On `origin/zheng-connector-oh-my-pi`, **not** on `sealed-fork` | **Drop — superseded by upstream's parallel `extensions/pi`.** `89a57ed5` is **not** an ancestor of `upstream/main` (`git merge-base --is-ancestor 89a57ed5 upstream/main` FAILS; it lives only on the side branch `upstream/feat/connector-openai-vercel` + `zheng-connector-oh-my-pi`). Upstream's `extensions/pi` is a **parallel reimplementation** with the same package name, rooted at a separate commit (`6f74fb8a`), evolved +1879/−269 over 15 files past our ancestor — **not** our `89a57ed5` evolved. It supersedes ours functionally and launches via `command: "pi"` (`extensions/pi/src/connector.ts:112`). We keep upstream's `extensions/pi` (untouched) and, under OQ-7(b), the connector consumes its exported `InboxTurn`. | +| `yaml` phantom-dep workarounds | folded inside connector commits | **Drop.** Upstream declares `yaml ^2.9.0` (`packages/core/package.json:52`); any sealed workaround for the headless-spawn breakage is dead weight on the new base. | + +Known debt carried forward (recorded, not blocking): our connector launches through a `tsx` shim +(`origin/sealed-fork:extensions/connector-oh-my-pi/src/connector.ts:9,52` — `command: TSX`), while +upstream's correct launcher pattern is the runtime binary (`extensions/pi/src/connector.ts:112`, +`command: "pi"`). The reapply lands the connector with its launcher; a follow-up task aligns it. If +OQ-7 resolves to (b) — port onto upstream pi's ledger — the launcher alignment folds naturally into +that port rather than being a separate follow-up. + +## Alternatives considered + +- **Merge upstream into `sealed-fork` instead of resetting** — preserves history in place, no + forced rebases. Rejected (assumption; OQ-6): a 723-commit merge produces one unreviewable + mega-conflict commit, leaves `main` still stale, and permanently forfeits the invariant that + `sealed-fork − main` = the reviewed sealed delta. +- **Rebase the 33 commits wholesale onto upstream** — keeps every commit. Rejected: at least two + groups (`@cotal-ai/pi`, yaml workarounds) are superseded, and the connector, mint, and KV-watch + groups need re-derivation, not mechanical replay; per-feature PRs give each group its own review + and test cycle. +- **New branch names (`main-mirror` + fresh integration branch) instead of force-moving the shared + ones** — avoids the force-push. Rejected as the default (surfaced in OQ-6), but the record's earlier + rationale (CI/docs consumers pointing at stale names) was unsubstantiated: `git grep sealed-fork` + over both trees' `.github/` and `docs/` is empty — no CI workflow or doc references the name. + The real consumers are the in-flight branches' open-PR base refs + local checkouts, and those + owners pay a 723-commit rebase under **either** model (their merge-bases are all pre-reset SHAs), + so rebase cost does not differentiate. The hard reset rests on the invariant argument alone, which + is sufficient. A **rename-cutover variant** (create `sealed-fork` anew at `8571c1bb`, tag+delete + the old, rename into place) achieves the identical end state without a force-push through branch + protection and lets GitHub retarget open PRs on rename — recorded as an execution detail for Matt + (OQ-6), not a distinct design. + +## Global Constraints + +- **Push-guard (the human gate):** the agent NEVER pushes or force-moves `main` or `sealed-fork` + (the shared bases), and NEVER pushes the archive tags. Those moves (T2 — incl. the tag pushes 0a/0b + — and T3) are specified here and executed by Matt by hand. Agents work only on feature branches + under allowlisted owners. +- **`jj-vine submit` is the only push path** for agent work — never `git push`, never + `gh pr create` (`rule://commit-conventions`, `skill://jj`). +- **Rebase-onto-current before submit:** every reapply branch rebases onto the current + `sealed-fork` tip immediately before each submit (`rule://sync-before-submit`). +- **Review loop on every PR** (`skill://review`): each reapply PR gets the full review cycle; + review fixes are additive commits, never amend+force-push. +- **No history destruction:** archive tags (`archive/pre-sync-main`, `archive/pre-sync-sealed-fork`) + MUST exist on origin before either force-move; every dropped/orphaned commit stays reachable + through them. +- **PR base:** all reapply PRs target `sealed-fork`, never `main`. `main` stays a pristine upstream + mirror — nothing sealed ever merges to it. +- **Verification floor per reapply PR:** `pnpm build` green + the feature's own smokes (named per + task) on the new base. Upstream's full `pnpm check` gate is aspirational on day one (it chains + 30+ live smokes); each task names its required subset. +- **`endpoint.ts` reconnect zone is single-owner:** T6 owns ALL `packages/core/src/endpoint.ts` + reconnect-path surgery (the watch-leak fix + `fe6d3ec1`'s backoff half). T4 does not touch + `endpoint.ts`. This removes the concurrent-patch hazard the "independent lanes" framing hid. +- **Ordering:** T1 → T2 (incl. 0a/0b) → T3 strictly serial; T4–T7 (reapply lanes) may run in + parallel after T3. T4 (connector) and T5 (zellij) are independent; T6 (KV-watch) and T7 (mint) + are independent ports. T8 (in-flight-branch rebases) starts after T3 and proceeds per-owner. + +## Plan + +### T1 — Pre-flight: freeze notice + this record's PR authored +Owner: coordinating agent. +Do: broadcast a freeze notice to the owners of the in-flight branches that `sealed-fork` will be +force-moved and they must rebase or close (see T8): `cotal-connector-renderer-design`, +`cotal-connector-renderer-tools`, `cotal-durable-acl-design`, `cotal-durable-acl-provision`, +`cotal-mint-reuse-identity`, `harness-sea1821-kv-watch-leak`, and `upstream-cotal-reconnect-logger`. +Author this design record's PR — but do **not** merge it before T3 (OQ-5): merging onto old +`sealed-fork` puts it on the exact history T3 orphans. It lands as (part of) the first post-reset +reapply-era PR, so `docs/designs/` becomes part of the new sealed delta. The archive tags are pushed +by Matt in T2 (0a/0b), not here. +Interfaces: consumes `origin/main`@`d55c6adb`, `origin/sealed-fork`@`0106d35c`; produces an +acknowledged notice from each branch owner + this record's PR authored (merge deferred to post-T3). +Verify: each in-flight-branch owner has acknowledged the freeze notice. + +### T2 — Human gate: archive tags + force-move `main` to upstream (Matt executes) +Owner: Matt (agent cannot push `main` or the tags). +Do (exact commands, run from a clone with both remotes): +```bash +# 0a/0b — archive tags FIRST (no-history-destruction precondition) +git push origin d55c6adb:refs/tags/archive/pre-sync-main +git push origin 0106d35c:refs/tags/archive/pre-sync-sealed-fork +# then the force-move +git fetch upstream main +git push origin +8571c1bbe585:refs/heads/main +``` +(If GitHub branch protection blocks the force-push, temporarily lift it — the repo's "sync fork" +will not fast-forward, since `main` has 7 sealed commits, so the force-push path is the real one.) +Interfaces: produces the two origin tags + `origin/main` = `8571c1bb`, 0 ahead / 0 behind +`upstream/main`. +Verify: `git ls-remote origin 'refs/tags/archive/*'` shows both tags at `d55c6adb` / `0106d35c`; +`git rev-parse origin/main` = `8571c1bbe585`; `git rev-list --count upstream/main..origin/main` = 0. + +### T3 — Human gate: reset `sealed-fork` to the new `main` (Matt executes) +Owner: Matt. +Do: `git push origin +8571c1bbe585:refs/heads/sealed-fork` (same SHA as T2 — sealed-fork starts +life as an exact copy of the new `main`). +Interfaces: consumes T2 complete; produces `origin/sealed-fork` = `8571c1bb`. From here +`sealed-fork − main` = merged reapply PRs only. +Verify: `git rev-parse origin/sealed-fork` = `8571c1bbe585`. + +### T4 — Reapply lane: oh-my-pi connector (semantic port — see OQ-7) +Owner: connector owner per OQ-3 (the hardening author is best placed for the InboxTurn port). +Do: branch `reapply-connector-oh-my-pi` off new `sealed-fork`; bring over +`extensions/connector-oh-my-pi/` from the archive tag `archive/pre-sync-sealed-fork`@`0106d35c` +(the byte-identical superset of PR #13 / zheng's branch — after T3, `origin/sealed-fork@0106d35c` is +dangling notation; resolve it via the tag). Then **port the InboxTurn seam per OQ-7's ruling**: the +tree's `loop.ts:1` import from `@cotal-ai/connector-core` will not build on the new base. Drop any +`yaml` workaround (upstream declares `yaml ^2.9.0`). Do NOT touch `extensions/pi` (upstream's) or +`packages/core/src/endpoint.ts` (T6 owns the reconnect zone). Re-check the `@cotal-ai/delivery` +declaration (`cd091f1b`) against upstream's current `bin/cotal.ts` resolution — drop if the build +resolves without it. The launcher-alignment (`tsx` shim → `command: "pi"`) folds into the port if +OQ-7 resolves to (b), else it is a recorded follow-up. +Interfaces: consumes new `sealed-fork` + `archive/pre-sync-sealed-fork:extensions/connector-oh-my-pi/` ++ (under OQ-7(b)) upstream's `extensions/pi` exported `InboxTurn`; produces one PR into `sealed-fork` +adding `extensions/connector-oh-my-pi`. +Verify: `pnpm build` green (the `loop.ts:1` break is resolved); connector smokes pass: +`extensions/connector-oh-my-pi/{interactive-loop,oh-my-pi-extension,oh-my-pi-peer}.smoke.ts`. **Note +the smokes fake the OLD inbox contract** (`oh-my-pi-peer.smoke.ts:72-95` implements `ackInbox` on a +FakeMesh) — under OQ-7(b) they must be re-derived against upstream's ledger API, or they will pass +green while asserting a contract the connector no longer uses. + +### T5 — Reapply lane: `@cotal-ai/zellij` runtime + placement +Owner: zheng (original author) or assigned lane. +Do: branch `reapply-zellij` off new `sealed-fork`; bring over `extensions/zellij/` from +`archive/pre-sync-sealed-fork`@`0106d35c` (feats `35c26085`, `48c084b8` + the 10 hardening commits, +squash-replayed as a small reviewable series); re-derive the CLI `--runtime zellij` allowlist changes +(`f2d0ba27`-equivalent) against upstream's current `implementations/cli/src/` (upstream has zero +zellij references — grep clean — so this is additive, patterned on upstream's `extensions/tmux`); +re-derive the zellij-binary CI caching (`d6112281`) against upstream's current CI workflows. +Interfaces: consumes new `sealed-fork` + `archive/pre-sync-sealed-fork:extensions/zellij/`; produces +one PR into `sealed-fork` adding `extensions/zellij` + CLI allowlist + CI caching. +Verify: `pnpm build` green; `extensions/zellij/smoke.ts` passes (in a throwaway zellij session per +`rule://zellij-session-safety`); `--runtime zellij` accepted by the CLI override allow-lists. + +### T6 — Reapply lane: KV-watch-leak fix (SEA-1821) + reconnect backoff +Owner: the `harness-sea1821-kv-watch-leak` lane owner. +Do: branch `reapply-sea1821-kv-watch` off new `sealed-fork`; **port** (not cherry-pick) the fix onto +upstream's drifted `packages/core/src/endpoint.ts` (3666 lines, no trace of the fix). Diff seam-to-seam +against the sealed-side anchors: the fields + `stopWatch()` at `origin/sealed-fork:endpoint.ts:234`, +teardown in `clearConnectionScoped` (upstream still has it at `endpoint.ts:762,765,881`), the ACL +grant for watch-consumer delete. Also port `fe6d3ec1`'s reconnect-backoff half (sealed exponential +3s→30s; upstream still has flat `retryMs=3000` at `endpoint.ts:391`) so all reconnect-path surgery is +in this one lane. Port `packages/core/smoke/reconnect-watch-leak.smoke.ts` (111 lines) + its root +`package.json` script. Red-green: run the ported smoke on unpatched upstream first and watch it fail +(`rule://red-green-testing`). +Interfaces: consumes new `sealed-fork` + fix content at `5e9a274f` (+ rounds `b99340c5`, `c21270d8`) ++ `fe6d3ec1`'s `packages/core` half; produces one PR into `sealed-fork` touching +`packages/core/src/endpoint.ts`, the smoke, root `package.json`. +Verify: smoke fails pre-patch, passes post-patch; `pnpm build` + `packages/core` tests green; +`packages/core/smoke/delivery-reconnect.smoke.ts` still green. + +### T7 — Reapply lane: cotal-mint identity-reuse +Owner: the `cotal-mint-reuse-identity` lane owner. +Do: branch `reapply-mint-identity-reuse` off new `sealed-fork`; **source the 7 commits from +`archive/pre-sync-main`@`d55c6adb`, NOT the `cotal-mint-reuse-identity` branch** (that branch is 4 +ahead of upstream — only 4 of the 7 commits; the `-v2` branch that carried the rest no longer exists +as a remote ref, so the 7 are reachable only via the tag / old main). Re-derive PR #9's behavior +(reuse existing identity unless `--force`; agent-profile gating; fail-loud on unparseable creds; +canonical-path + symlink rejection) onto upstream's rewritten +`implementations/cli/src/commands/mint.ts` (115 lines, no reuse logic — only the `--force` guard at +`mint.ts:37-38`, which sits inside upstream's **new `--signer` mode** at `mint.ts:30-45` that the +re-derivation MUST preserve). `packages/core/src/identity.ts` still exists upstream (drifted +66/−16) +— re-anchor against it. Port the red-green regressions (`bd3edb6d`, `994e85ce`) as the lane's tests. +Interfaces: consumes new `sealed-fork` + the 7 commits `dca37915..d55c6adb` via `archive/pre-sync-main`; +produces one PR into `sealed-fork` touching `implementations/cli/src/commands/mint.ts`, +`packages/core/src/identity.ts`, their smokes, root `package.json`. +Verify: ported mint regression tests green; `pnpm build` green; manual smoke: `cotal mint` twice → +second run reuses identity; `--force` overwrites; `--signer` mode still works. + +### T8 — In-flight branch rebases (coordination, per-owner) +Owner: each branch's owner; tracked by the coordinating agent. +Do: after T3, each in-flight branch rebases onto the new `sealed-fork` (or its lane's reapply branch +where it depends on one). Dispositions: `harness-sea1821-kv-watch-leak` (0 ahead of `sealed-fork` — +fully merged) and `cotal-mint-reuse-identity` are **superseded by T6/T7 → close, don't rebase**; +`upstream-cotal-reconnect-logger` is **superseded by `fe6d3ec1` in T6 → close** (T9); the four +renderer/ACL design branches rebase. This is the recorded coordination cost of the hard reset: every +rebasing owner pays one rebase across a 723-commit base jump; content conflicts are likely and each +owner triages their own. +Interfaces: consumes T3 complete + T1's notice; produces each branch either rebased onto +`sealed-fork`@`8571c1bb`+ or closed-as-superseded. +Verify: `git branch -r --no-merged origin/sealed-fork` contains only live, rebased branches; no +branch still bases on `0106d35c` ancestry. + +### T9 — Post-migration: PR #13 disposition + branch hygiene +Owner: coordinating agent (with Matt's OQ-1/OQ-3 answers). +Do: close PR #13 per OQ-1 answer (its content is fully contained in T4's source); delete or archive +the superseded connector/reconnect branches once T4/T6 merge (`upstream-cotal-181-omp-connector`, +`zheng-connector-oh-my-pi`, `zheng-connector-upstream`, `upstream-cotal-reconnect-logger`); confirm +the archive tags remain. +Interfaces: consumes T4/T6 merged + OQ answers; produces a clean origin branch list. +Verify: PR #13 closed with a pointer to the T4 PR; `git branch -r` shows no stale connector/reconnect +branches; `git ls-remote origin 'refs/tags/archive/*'` still shows both tags. + +## Tasks + +- [ ] T1 — Freeze notice acknowledged by all in-flight owners + this record's PR authored (merge deferred to post-T3) +- [ ] T2 — (HUMAN) archive tags pushed (0a/0b) + `main` force-moved to `8571c1bb`, verified 0/0 vs upstream +- [ ] T3 — (HUMAN) `sealed-fork` reset to new `main`, verified +- [ ] T4 — Connector reapply PR merged (build green — InboxTurn ported per OQ-7 — + connector smokes green) +- [ ] T5 — Zellij reapply PR merged (build + zellij smoke green) +- [ ] T6 — KV-watch + reconnect-backoff port PR merged (red-green smoke evidence attached) +- [ ] T7 — Mint identity-reuse port PR merged (regression tests green; `--signer` preserved) +- [ ] T8 — All in-flight branches rebased or closed-as-superseded +- [ ] T9 — PR #13 closed; stale connector/reconnect branches removed + +## Open Questions + +All are load-bearing; each records the assumption this design was written against and a +recommendation. Matt rules on all of them before the freeze. + +1. **PR #13 disposition** — it's a byte-identical duplicate of `sealed-fork`'s connector on a stale + base (`git diff --quiet` clean on `extensions/connector-oh-my-pi/`). Close it, or keep as + reference until the reapply PR exists? + *Recommendation:* close it once T4's PR is open, with a cross-link. + *Designed against:* PR #13 closes (T9); T4 sources from `archive/pre-sync-sealed-fork`@`0106d35c`, + which contains everything PR #13 has. +2. **Reapply-set curation** — reapply the curated subset above, or more/less? In particular the drop + list: `yaml` workarounds (upstream declares `yaml ^2.9.0`), `@cotal-ai/pi` (`89a57ed5` — a side + branch NOT in upstream's history; upstream's `extensions/pi` is a same-named parallel + reimplementation rooted at `6f74fb8a` that supersedes ours functionally), and + `upstream-cotal-reconnect-logger` (subsumed by `fe6d3ec1` in T6). + *Recommendation:* the curated set — reapply connector, zellij, mint, KV-watch(+backoff); drop the + three listed. + *Designed against:* the curated set (T4–T7); the three drops. +3. **Connector reconciliation with zheng** — my hardened connector (zheng's + ~593 lines of + race-hardening; byte-identical to `sealed-fork`'s) vs zheng's on-newer-base branch: which lineage + reapplies, and who owns T4? + *Recommendation:* reapply the `sealed-fork` (hardened) lineage — it is a strict superset — and + assign T4 to the hardening author (best placed to resolve the InboxTurn port). + *Designed against:* T4 sources the hardened `sealed-fork` lineage; ownership left to Matt. +4. **Execution ownership of the main-move** — T2/T3 are the human gate (agent cannot push `main` or + tags). Who executes each step, and is this a solo lane or a manager-coordinated fleet effort + (given T8's in-flight branches)? + *Recommendation:* Matt runs T2/T3 (commands given verbatim above); a coordinating agent drives + T1/T8/T9 and the reapply lanes run as a small fleet. + *Designed against:* Matt executes T2/T3; fleet-coordinated reapply. +5. **Where do sealed-only artifacts live** so a `sealed-fork` reset doesn't wipe them — e.g. THIS + design record, which the reset orphans if it merges to old `sealed-fork`? + *Recommendation:* do NOT merge this record's PR before T3; land it as (part of) the first + post-reset reapply-era PR, making `docs/designs/` part of the new sealed delta. Never on `main`. + *Designed against:* T1 authors the PR but defers its merge to post-T3. +6. **`sealed-fork` reset = force-moving a shared branch** other lanes base on. Hard reset (owners pay + one rebase each, T8), or the rename-cutover variant (identical end state, no force-push through + branch protection, GitHub retargets open PRs)? + *Recommendation:* hard reset — a 723-commit merge is unreviewable and forfeits the + `sealed-fork − main = reviewed sealed delta` invariant; the archive tag keeps old history + reachable and T1's notice bounds the surprise. Rename-cutover is an acceptable execution variant. + *Designed against:* hard reset with T1 notice + T8 per-owner rebase/close. +7. **Connector InboxTurn strategy (the re-priced headline)** — the connector's `loop.ts:1` imports + `InboxTurn` from `@cotal-ai/connector-core`, but upstream deleted that seam (`inbox-turn.ts` gone, + `ackInbox` gone, drains via `drainInboxIds` + a two-site ack model) and re-landed `InboxTurn` in + `extensions/pi` with a **different contract** (tombstone ledger, exported at `index.ts:4` for SDK + embedders). Two ports: + - **(a) Resurrect the old seam** — re-add sealed's `inbox-turn.ts` + `ackInbox` to upstream's + rewritten `connector-core`. Risk: bolts a parallel ack surface onto a MeshAgent whose ack model + upstream redesigned (focus-ingest acks, exact-id drains) — a semantic conflict `pnpm build` will + NOT flag, surfacing later as double-surfaced/lost mesh messages, and the existing smokes fake the + OLD contract so they pass green anyway. + - **(b) Migrate onto upstream pi's exported ledger** — rewrite `loop.ts` against + `@cotal-ai/pi`'s `InboxTurn` (`extensions/pi/src/index.ts:4`, which exists FOR this use case). + Composes cleanly with "drop our `@cotal-ai/pi`" + "keep upstream's `extensions/pi`", dissolves + most of `ae2de4e1`'s re-derivation, and folds in the launcher alignment. Cost: mapping + `loop.ts`'s commit/abandon/extend calls onto the tombstone-ledger API (`CommitResult`, + `drainInboxIds`) — a real but bounded rewrite, and the smokes must be re-derived to the ledger + contract. + *Recommendation:* **(b)** — it is the composing option and avoids reintroducing a deleted seam + against a redesigned ack model. + *Designed against:* (b); T4 consumes upstream pi's exported `InboxTurn`, smokes re-derived to the + ledger. If Matt rules (a), T4 re-adds the seam and the smokes stay as-is. From e361b29d49ce130beec5302c0849c0b48e2e04cb Mon Sep 17 00:00:00 2001 From: seal Date: Sun, 9 Aug 2026 16:12:04 -0400 Subject: [PATCH 8/9] docs(platform): fold Matt's rulings + review fixes into fork-sync record Converts the 7 Open Questions to frozen Decisions (D1-D8) per Matt's rulings on PR #14, and fixes the review agent's findings (high 0, medium 3, low 2). **Decisions:** D1 close PR #13 (superseded duplicate); D2 curated reapply set (4 groups, 3 drops); D3 hardened connector lineage, service-owner owns T4; D4 agent runs the human-gated main-move; D5 sealed artifacts land on post-reset sealed-fork (never main); D6 hard reset + archive tags; D7 connector InboxTurn migrates onto upstream pi's exported ledger (option b); D8 reapply PRs base on sealed-fork via trunk(). **Review fixes:** M1 mint git range `dca37915^..d55c6adb` (two-dot was exclusive, dropped the foundational commit, 6 not 7); M2 T6 sources fix content via `archive/pre-sync-sealed-fork` (bare SHAs orphaned post-T3); M3 PR #14 disposition (review vehicle on main, closed/retargeted at T9, never merges to main); L1 loop.ts value-import vs type-import precision; L2 path-qualified `connector-core/src/agent.ts:462` cite. Spec-impact: none. Refs #181 Co-authored-by: Matt Wilkinson --- docs/designs/platform/cotal-fork-sync.md | 259 +++++++++++------------ 1 file changed, 127 insertions(+), 132 deletions(-) diff --git a/docs/designs/platform/cotal-fork-sync.md b/docs/designs/platform/cotal-fork-sync.md index dfc9dd5970..6f6d705efe 100644 --- a/docs/designs/platform/cotal-fork-sync.md +++ b/docs/designs/platform/cotal-fork-sync.md @@ -1,6 +1,6 @@ # Cotal fork-sync + branch-model reset -Status: Draft +Status: Draft (all Open Questions ruled by Matt — see Decisions; the PR merge freezes the record) ## Problem / Intent @@ -26,7 +26,7 @@ per-feature verification plan. 2. **Reset `sealed-fork`** — same human gate force-moves `sealed-fork` to the new `main`. This is a hard reset, not a merge: a merge of 723 upstream commits into 33 sealed commits would produce a conflict swamp with no review story, and the whole point of the new model is that `sealed-fork`'s - delta over `main` is exactly the set of reviewed reapply PRs (assumption; OQ-6). Owners of the + delta over `main` is exactly the set of reviewed reapply PRs (Decision D6). Owners of the in-flight branches are notified before the move and rebase after it (coordination cost recorded in the Plan). 3. **Reapply via PRs, one feature-group per PR** — the 33+7 sealed commits collapse into 4 reapply @@ -34,33 +34,33 @@ per-feature verification plan. explicit **drop list** for work upstream has superseded. Each lane is a feature branch off the new `sealed-fork`, cherry-picked/re-derived, submitted via `jj-vine submit`, review-looped, and merged into `sealed-fork` — never pushed to it directly. **These are not uniformly cheap:** the connector - (T4) reapplies a tree byte-identical to `sealed-fork`'s, but its `loop.ts:1` import of `InboxTurn` - from `@cotal-ai/connector-core` dangles on the new base (upstream deleted that seam — see the - inventory + OQ-7), so T4 is an API-migration port on par with T6/T7, not a tree-copy. + (T4) reapplies a tree byte-identical to `sealed-fork`'s, but its `loop.ts:1` **value-import** of + `InboxTurn` from `@cotal-ai/connector-core` dangles on the new base (upstream deleted that seam — + see the inventory + Decision D7), so T4 is an API-migration port on par with T6/T7, not a + tree-copy. -Curated inventory (verified against the clone this session): +Curated inventory (verified against the clone this session, re-confirmed by the review pass): | Group | Commits (on `origin/sealed-fork` / `origin/main`) | Disposition | |---|---|---| -| oh-my-pi connector | `55bc0c60` (feat) + 12 fix/test commits through `568c8175`, incl. `e2347788` (pi-coding-agent 16.3.12 + zod), `ae2de4e1` (connector-core InboxTurn), `f6f91657` (CI smokes), `cd091f1b` (`@cotal-ai/delivery` decl) | **Reapply as a semantic PORT (not a tree-copy) — see OQ-7.** Upstream has no `extensions/connector-oh-my-pi` (`git ls-tree upstream/main extensions/` — only cmux, connector-{claude-code,codex,core,hermes,opencode}, orca, pi, tmux), so the tree lands with no path collision, and its `src/` is byte-identical to PR #13's branch (`git diff --quiet origin/sealed-fork origin/upstream-cotal-181-omp-connector -- extensions/connector-oh-my-pi/` is clean over the whole extension). **But byte-identity only proves our two copies match each other, not that the tree composes with upstream.** It does not: `loop.ts:1` imports `InboxTurn`/`InboxSource` from `@cotal-ai/connector-core`, and upstream deleted `extensions/connector-core/src/inbox-turn.ts` (`git cat-file -e` fails) and has no `ackInbox` (it drains via `drainInboxIds`, agent.ts:462, with a two-site ack model). Upstream re-landed `InboxTurn` inside `extensions/pi` with a **different contract** (tombstone ledger, `TOMBSTONE_CAP=4096`, `extensions/pi/src/inbox-turn.ts:14,21`), exported at `extensions/pi/src/index.ts:4` "for SDK embedders driving their own session". `pnpm build` fails at `loop.ts:1` until the connector is ported to one of OQ-7's two strategies. | +| oh-my-pi connector | `55bc0c60` (feat) + 12 fix/test commits through `568c8175`, incl. `e2347788` (pi-coding-agent 16.3.12 + zod), `ae2de4e1` (connector-core InboxTurn), `f6f91657` (CI smokes), `cd091f1b` (`@cotal-ai/delivery` decl) | **Reapply as a semantic PORT (not a tree-copy) — see Decision D7.** Upstream has no `extensions/connector-oh-my-pi` (`git ls-tree upstream/main extensions/` — only cmux, connector-{claude-code,codex,core,hermes,opencode}, orca, pi, tmux), so the tree lands with no path collision, and its `src/` is byte-identical to PR #13's branch (`git diff --quiet origin/sealed-fork origin/upstream-cotal-181-omp-connector -- extensions/connector-oh-my-pi/` is clean over the whole extension). **But byte-identity only proves our two copies match each other, not that the tree composes with upstream.** It does not: `loop.ts:1` **value-imports** `InboxTurn` (and `loop.ts:2` type-imports `InboxItem`/`InboxSource`) from `@cotal-ai/connector-core` — the value import is the build-breaker. Upstream deleted `extensions/connector-core/src/inbox-turn.ts` (`git cat-file -e` fails) and has no `ackInbox` (it drains via `drainInboxIds`, `extensions/connector-core/src/agent.ts:462`, with a two-site ack model). Upstream re-landed `InboxTurn` inside `extensions/pi` with a **different contract** (tombstone ledger, `TOMBSTONE_CAP=4096`, `extensions/pi/src/inbox-turn.ts:14,21`), exported at `extensions/pi/src/index.ts:4` "for SDK embedders driving their own session". `pnpm build` fails at `loop.ts:1` until the connector is ported per D7. | | `@cotal-ai/zellij` runtime + placement | `35c26085`, `48c084b8` (feats) + 10 hardening commits through `a6ecda6b` | **Reapply.** Upstream has no `extensions/zellij` and no zellij references in `implementations/cli/src/` (grep clean); upstream's `extensions/tmux` is the sibling pattern to re-anchor the CLI-allowlist commits (`f2d0ba27`) against. | -| cotal-mint identity-reuse | The 7 commits on `origin/main` only (`dca37915`..`d55c6adb`, PR #9) — **not** on `sealed-fork` (`merge-base --is-ancestor d55c6adb origin/sealed-fork` fails) | **Reapply, expect conflicts.** Upstream `implementations/cli/src/commands/mint.ts` is 115 lines vs our 158 and has no identity-reuse (`grep 'reuse'` clean); its `--force` overwrite guard (`mint.ts:37-38`) sits inside a **new `--signer` mode** (`values.signer`, `mint.ts:30-45`) that did not exist in our version — the re-derivation must preserve it. Source from the archive tag, not the `cotal-mint-reuse-identity` branch (that branch carries only 4 of the 7 commits — see T7). | +| cotal-mint identity-reuse | The 7 commits on `origin/main` only (`dca37915^..d55c6adb`, PR #9) — **not** on `sealed-fork` (`merge-base --is-ancestor d55c6adb origin/sealed-fork` fails). Note the `^` — `dca37915..d55c6adb` (two-dot, exclusive) drops `dca37915` itself (the foundational reuse commit) and yields only 6; the inclusive set is `dca37915^..d55c6adb` = 7. | **Reapply, expect conflicts.** Upstream `implementations/cli/src/commands/mint.ts` is 115 lines vs our 158 and has no identity-reuse (`grep 'reuse'` clean); its `--force` overwrite guard (`mint.ts:37-38`) sits inside a **new `--signer` mode** (`values.signer`, `mint.ts:29-42`) that did not exist in our version — the re-derivation must preserve it. Source from the archive tag, not the `cotal-mint-reuse-identity` branch (that branch carries only 4 of the 7 commits — see T7). | | KV-watch-leak fix (SEA-1821) | `5e9a274f` + review rounds `b99340c5`, `c21270d8` (PR #12) | **Reapply, re-derive.** Upstream `packages/core/src/endpoint.ts` (3666 lines) has **no** `presenceWatch`/`channelWatch`/`stopWatch` (grep clean); the sealed fix lives at `origin/sealed-fork:packages/core/src/endpoint.ts:234,379-382`. The file drifted heavily (reconnect machinery now at `endpoint.ts:375-391`), so this is a port, not a clean cherry-pick. The smoke `packages/core/smoke/reconnect-watch-leak.smoke.ts` ports with it. **This lane also absorbs `fe6d3ec1`'s `packages/core` half** (exponential reconnect backoff) so all `endpoint.ts` reconnect surgery lands in one lane (see T6). | | reconnect-logger (`origin/upstream-cotal-reconnect-logger`, tip `0041740f`, unmerged — 1 ahead of `sealed-fork`) | Standalone upstream-shaped variant of `fe6d3ec1` (edge logging, injectable MeshAgent logger, exponential endpoint backoff) | **Drop — superseded.** Its content is subsumed by `fe6d3ec1`, folded into T6's `endpoint.ts` port. Listed here so it is neither lost nor left basing on orphaned history; freeze-noticed in T1 and cleaned up in T9. | -| `@cotal-ai/pi` (`89a57ed5`) | On `origin/zheng-connector-oh-my-pi`, **not** on `sealed-fork` | **Drop — superseded by upstream's parallel `extensions/pi`.** `89a57ed5` is **not** an ancestor of `upstream/main` (`git merge-base --is-ancestor 89a57ed5 upstream/main` FAILS; it lives only on the side branch `upstream/feat/connector-openai-vercel` + `zheng-connector-oh-my-pi`). Upstream's `extensions/pi` is a **parallel reimplementation** with the same package name, rooted at a separate commit (`6f74fb8a`), evolved +1879/−269 over 15 files past our ancestor — **not** our `89a57ed5` evolved. It supersedes ours functionally and launches via `command: "pi"` (`extensions/pi/src/connector.ts:112`). We keep upstream's `extensions/pi` (untouched) and, under OQ-7(b), the connector consumes its exported `InboxTurn`. | +| `@cotal-ai/pi` (`89a57ed5`) | On `origin/zheng-connector-oh-my-pi`, **not** on `sealed-fork` | **Drop — superseded by upstream's parallel `extensions/pi`.** `89a57ed5` is **not** an ancestor of `upstream/main` (`git merge-base --is-ancestor 89a57ed5 upstream/main` FAILS; it lives only on the side branch `upstream/feat/connector-openai-vercel` + `zheng-connector-oh-my-pi`). Upstream's `extensions/pi` is a **parallel reimplementation** with the same package name, rooted at a separate commit (`6f74fb8a`), evolved +1879/−269 over 15 files past our ancestor — **not** our `89a57ed5` evolved. It supersedes ours functionally and launches via `command: "pi"` (`extensions/pi/src/connector.ts:112`). We keep upstream's `extensions/pi` (untouched) and, per D7, the connector consumes its exported `InboxTurn`. | | `yaml` phantom-dep workarounds | folded inside connector commits | **Drop.** Upstream declares `yaml ^2.9.0` (`packages/core/package.json:52`); any sealed workaround for the headless-spawn breakage is dead weight on the new base. | -Known debt carried forward (recorded, not blocking): our connector launches through a `tsx` shim +Known debt resolved by D7: our connector launched through a `tsx` shim (`origin/sealed-fork:extensions/connector-oh-my-pi/src/connector.ts:9,52` — `command: TSX`), while upstream's correct launcher pattern is the runtime binary (`extensions/pi/src/connector.ts:112`, -`command: "pi"`). The reapply lands the connector with its launcher; a follow-up task aligns it. If -OQ-7 resolves to (b) — port onto upstream pi's ledger — the launcher alignment folds naturally into -that port rather than being a separate follow-up. +`command: "pi"`). Since D7 ports the connector onto upstream pi's exported ledger (option b), the +launcher alignment folds into that port rather than being a separate follow-up. ## Alternatives considered - **Merge upstream into `sealed-fork` instead of resetting** — preserves history in place, no - forced rebases. Rejected (assumption; OQ-6): a 723-commit merge produces one unreviewable + forced rebases. Rejected (Decision D6): a 723-commit merge produces one unreviewable mega-conflict commit, leaves `main` still stale, and permanently forfeits the invariant that `sealed-fork − main` = the reviewed sealed delta. - **Rebase the 33 commits wholesale onto upstream** — keeps every commit. Rejected: at least two @@ -68,16 +68,15 @@ that port rather than being a separate follow-up. groups need re-derivation, not mechanical replay; per-feature PRs give each group its own review and test cycle. - **New branch names (`main-mirror` + fresh integration branch) instead of force-moving the shared - ones** — avoids the force-push. Rejected as the default (surfaced in OQ-6), but the record's earlier - rationale (CI/docs consumers pointing at stale names) was unsubstantiated: `git grep sealed-fork` - over both trees' `.github/` and `docs/` is empty — no CI workflow or doc references the name. - The real consumers are the in-flight branches' open-PR base refs + local checkouts, and those - owners pay a 723-commit rebase under **either** model (their merge-bases are all pre-reset SHAs), - so rebase cost does not differentiate. The hard reset rests on the invariant argument alone, which - is sufficient. A **rename-cutover variant** (create `sealed-fork` anew at `8571c1bb`, tag+delete - the old, rename into place) achieves the identical end state without a force-push through branch - protection and lets GitHub retarget open PRs on rename — recorded as an execution detail for Matt - (OQ-6), not a distinct design. + ones** — avoids the force-push. Rejected as the default (Decision D6), but the concrete rename + cost is small: `git grep sealed-fork` over both trees' `.github/` and `docs/` is empty — no CI + workflow or doc references the name. The real consumers are the in-flight branches' open-PR base + refs + local checkouts, and those owners pay a 723-commit rebase under **either** model (their + merge-bases are all pre-reset SHAs), so rebase cost does not differentiate. The hard reset rests + on the invariant argument alone, which is sufficient. A **rename-cutover variant** (create + `sealed-fork` anew at `8571c1bb`, tag+delete the old, rename into place) achieves the identical end + state without a force-push through branch protection and lets GitHub retarget open PRs on rename — + recorded as an acceptable execution variant of D6, not a distinct design. ## Global Constraints @@ -87,6 +86,14 @@ that port rather than being a separate follow-up. under allowlisted owners. - **`jj-vine submit` is the only push path** for agent work — never `git push`, never `gh pr create` (`rule://commit-conventions`, `skill://jj`). +- **Reapply PRs base on `sealed-fork` via `trunk()` (Decision D8).** `jj-vine` has no base-branch + flag; it derives a PR's base from the DAG (parent bookmark, else `trunk()`), and it cannot base a + PR on a bookmark that is neither trunk nor a submitted stack bookmark (empirically: it hangs on an + untracked `sealed-fork@origin` base and panics — `bookmark.rs:738` — on a tracked one). So once + `sealed-fork` IS the integration trunk (post-T3), set the clone's `revset-alias."trunk()" = + "sealed-fork@origin"` and jj-vine bases every reapply PR (T4–T7) on `sealed-fork` natively. This is + self-applied by the coordinating agent + re-verified (`jj config list --repo` shows the alias; + a `--dry-run` submit shows base `sealed-fork`). - **Rebase-onto-current before submit:** every reapply branch rebases onto the current `sealed-fork` tip immediately before each submit (`rule://sync-before-submit`). - **Review loop on every PR** (`skill://review`): each reapply PR gets the full review cycle; @@ -95,7 +102,8 @@ that port rather than being a separate follow-up. MUST exist on origin before either force-move; every dropped/orphaned commit stays reachable through them. - **PR base:** all reapply PRs target `sealed-fork`, never `main`. `main` stays a pristine upstream - mirror — nothing sealed ever merges to it. + mirror — nothing sealed ever merges to it. (This record's own PR #14, opened on `main` as a review + vehicle, is disposed of by T1/T9 — it does not merge to `main`.) - **Verification floor per reapply PR:** `pnpm build` green + the feature's own smokes (named per task) on the new base. Upstream's full `pnpm check` gate is aspirational on day one (it chains 30+ live smokes); each task names its required subset. @@ -114,12 +122,14 @@ Do: broadcast a freeze notice to the owners of the in-flight branches that `seal force-moved and they must rebase or close (see T8): `cotal-connector-renderer-design`, `cotal-connector-renderer-tools`, `cotal-durable-acl-design`, `cotal-durable-acl-provision`, `cotal-mint-reuse-identity`, `harness-sea1821-kv-watch-leak`, and `upstream-cotal-reconnect-logger`. -Author this design record's PR — but do **not** merge it before T3 (OQ-5): merging onto old -`sealed-fork` puts it on the exact history T3 orphans. It lands as (part of) the first post-reset -reapply-era PR, so `docs/designs/` becomes part of the new sealed delta. The archive tags are pushed -by Matt in T2 (0a/0b), not here. +This record's PR (#14) is authored on `main` as a **review vehicle** so Matt + the review agent can +ratify the design before T2/T3 execute — but it does **not** merge to `main` (Decision D5): T2 +force-moves `main` to upstream, which orphans #14's base, so #14 is closed/retargeted at T9 and the +record re-lands as (part of) the first post-reset reapply-era PR targeting `sealed-fork`, making +`docs/designs/` part of the new sealed delta. The archive tags are pushed by Matt in T2 (0a/0b), +not here. Interfaces: consumes `origin/main`@`d55c6adb`, `origin/sealed-fork`@`0106d35c`; produces an -acknowledged notice from each branch owner + this record's PR authored (merge deferred to post-T3). +acknowledged notice from each branch owner + PR #14 (review vehicle; not merged to `main`). Verify: each in-flight-branch owner has acknowledged the freeze notice. ### T2 — Human gate: archive tags + force-move `main` to upstream (Matt executes) @@ -136,7 +146,8 @@ git push origin +8571c1bbe585:refs/heads/main (If GitHub branch protection blocks the force-push, temporarily lift it — the repo's "sync fork" will not fast-forward, since `main` has 7 sealed commits, so the force-push path is the real one.) Interfaces: produces the two origin tags + `origin/main` = `8571c1bb`, 0 ahead / 0 behind -`upstream/main`. +`upstream/main`. Note: the force-move orphans PR #14's base — handle #14 at/after T3 (T9), do not +leave it dangling. Verify: `git ls-remote origin 'refs/tags/archive/*'` shows both tags at `d55c6adb` / `0106d35c`; `git rev-parse origin/main` = `8571c1bbe585`; `git rev-list --count upstream/main..origin/main` = 0. @@ -148,26 +159,28 @@ Interfaces: consumes T2 complete; produces `origin/sealed-fork` = `8571c1bb`. Fr `sealed-fork − main` = merged reapply PRs only. Verify: `git rev-parse origin/sealed-fork` = `8571c1bbe585`. -### T4 — Reapply lane: oh-my-pi connector (semantic port — see OQ-7) -Owner: connector owner per OQ-3 (the hardening author is best placed for the InboxTurn port). +### T4 — Reapply lane: oh-my-pi connector (semantic port onto upstream pi's ledger — D7) +Owner: `upstream-cotal/service-owner` (the hardening author — Decision D3). Do: branch `reapply-connector-oh-my-pi` off new `sealed-fork`; bring over `extensions/connector-oh-my-pi/` from the archive tag `archive/pre-sync-sealed-fork`@`0106d35c` (the byte-identical superset of PR #13 / zheng's branch — after T3, `origin/sealed-fork@0106d35c` is -dangling notation; resolve it via the tag). Then **port the InboxTurn seam per OQ-7's ruling**: the -tree's `loop.ts:1` import from `@cotal-ai/connector-core` will not build on the new base. Drop any -`yaml` workaround (upstream declares `yaml ^2.9.0`). Do NOT touch `extensions/pi` (upstream's) or -`packages/core/src/endpoint.ts` (T6 owns the reconnect zone). Re-check the `@cotal-ai/delivery` -declaration (`cd091f1b`) against upstream's current `bin/cotal.ts` resolution — drop if the build -resolves without it. The launcher-alignment (`tsx` shim → `command: "pi"`) folds into the port if -OQ-7 resolves to (b), else it is a recorded follow-up. +dangling notation; resolve it via the tag). Then **port the InboxTurn seam per D7 (option b):** +rewrite `loop.ts` against `@cotal-ai/pi`'s exported `InboxTurn` (`extensions/pi/src/index.ts:4`) — +map the connector's commit/abandon/extend calls onto the tombstone-ledger API (`CommitResult`, +`drainInboxIds`). This dissolves `ae2de4e1`'s connector-core re-derivation (do NOT re-add +`inbox-turn.ts`/`ackInbox` to upstream's `connector-core`). Drop any `yaml` workaround (upstream +declares `yaml ^2.9.0`). Fold in the launcher alignment (`tsx` shim → `command: "pi"`). Do NOT +touch `extensions/pi` (upstream's) or `packages/core/src/endpoint.ts` (T6 owns the reconnect zone). +Re-check the `@cotal-ai/delivery` declaration (`cd091f1b`) against upstream's current `bin/cotal.ts` +resolution — drop if the build resolves without it. Interfaces: consumes new `sealed-fork` + `archive/pre-sync-sealed-fork:extensions/connector-oh-my-pi/` -+ (under OQ-7(b)) upstream's `extensions/pi` exported `InboxTurn`; produces one PR into `sealed-fork` -adding `extensions/connector-oh-my-pi`. ++ upstream's `extensions/pi` exported `InboxTurn`; produces one PR into `sealed-fork` adding +`extensions/connector-oh-my-pi`. Verify: `pnpm build` green (the `loop.ts:1` break is resolved); connector smokes pass: -`extensions/connector-oh-my-pi/{interactive-loop,oh-my-pi-extension,oh-my-pi-peer}.smoke.ts`. **Note -the smokes fake the OLD inbox contract** (`oh-my-pi-peer.smoke.ts:72-95` implements `ackInbox` on a -FakeMesh) — under OQ-7(b) they must be re-derived against upstream's ledger API, or they will pass -green while asserting a contract the connector no longer uses. +`extensions/connector-oh-my-pi/{interactive-loop,oh-my-pi-extension,oh-my-pi-peer}.smoke.ts`. **The +smokes currently fake the OLD inbox contract** (`oh-my-pi-peer.smoke.ts:72-95` implements `ackInbox` +on a FakeMesh) — under D7(b) they MUST be re-derived against upstream's ledger API, or they pass +green while asserting a contract the connector no longer uses (a rubber-stamp, not a test). ### T5 — Reapply lane: `@cotal-ai/zellij` runtime + placement Owner: zheng (original author) or assigned lane. @@ -185,17 +198,22 @@ Verify: `pnpm build` green; `extensions/zellij/smoke.ts` passes (in a throwaway ### T6 — Reapply lane: KV-watch-leak fix (SEA-1821) + reconnect backoff Owner: the `harness-sea1821-kv-watch-leak` lane owner. Do: branch `reapply-sea1821-kv-watch` off new `sealed-fork`; **port** (not cherry-pick) the fix onto -upstream's drifted `packages/core/src/endpoint.ts` (3666 lines, no trace of the fix). Diff seam-to-seam -against the sealed-side anchors: the fields + `stopWatch()` at `origin/sealed-fork:endpoint.ts:234`, -teardown in `clearConnectionScoped` (upstream still has it at `endpoint.ts:762,765,881`), the ACL -grant for watch-consumer delete. Also port `fe6d3ec1`'s reconnect-backoff half (sealed exponential -3s→30s; upstream still has flat `retryMs=3000` at `endpoint.ts:391`) so all reconnect-path surgery is -in this one lane. Port `packages/core/smoke/reconnect-watch-leak.smoke.ts` (111 lines) + its root -`package.json` script. Red-green: run the ported smoke on unpatched upstream first and watch it fail +upstream's drifted `packages/core/src/endpoint.ts` (3666 lines, no trace of the fix). **Source the +fix content from `archive/pre-sync-sealed-fork`@`0106d35c`** — the SHAs `5e9a274f`, `b99340c5`, +`c21270d8`, and `fe6d3ec1` are reachable ONLY from `0106d35c` and are NOT ancestors of `d55c6adb`, +so after T3 the bare SHAs are dangling notation; resolve them via the tag (the +`harness-sea1821-kv-watch-leak` branch is closed at T8, so it is not a durable source either). Diff +seam-to-seam against the sealed-side anchors: the fields + `stopWatch()` at +`archive/pre-sync-sealed-fork:endpoint.ts:234`, teardown in `clearConnectionScoped` (upstream still +has it at `endpoint.ts:762,765,881`), the ACL grant for watch-consumer delete. Also port +`fe6d3ec1`'s reconnect-backoff half (sealed exponential 3s→30s; upstream still has flat +`retryMs=3000` at `endpoint.ts:391`) so all reconnect-path surgery is in this one lane. Port +`packages/core/smoke/reconnect-watch-leak.smoke.ts` (111 lines) + its root `package.json` script. +Red-green: run the ported smoke on unpatched upstream first and watch it fail (`rule://red-green-testing`). Interfaces: consumes new `sealed-fork` + fix content at `5e9a274f` (+ rounds `b99340c5`, `c21270d8`) -+ `fe6d3ec1`'s `packages/core` half; produces one PR into `sealed-fork` touching -`packages/core/src/endpoint.ts`, the smoke, root `package.json`. ++ `fe6d3ec1`'s `packages/core` half, all via `archive/pre-sync-sealed-fork`@`0106d35c`; produces one +PR into `sealed-fork` touching `packages/core/src/endpoint.ts`, the smoke, root `package.json`. Verify: smoke fails pre-patch, passes post-patch; `pnpm build` + `packages/core` tests green; `packages/core/smoke/delivery-reconnect.smoke.ts` still green. @@ -208,12 +226,13 @@ as a remote ref, so the 7 are reachable only via the tag / old main). Re-derive (reuse existing identity unless `--force`; agent-profile gating; fail-loud on unparseable creds; canonical-path + symlink rejection) onto upstream's rewritten `implementations/cli/src/commands/mint.ts` (115 lines, no reuse logic — only the `--force` guard at -`mint.ts:37-38`, which sits inside upstream's **new `--signer` mode** at `mint.ts:30-45` that the +`mint.ts:37-38`, which sits inside upstream's **new `--signer` mode** at `mint.ts:29-42` that the re-derivation MUST preserve). `packages/core/src/identity.ts` still exists upstream (drifted +66/−16) — re-anchor against it. Port the red-green regressions (`bd3edb6d`, `994e85ce`) as the lane's tests. -Interfaces: consumes new `sealed-fork` + the 7 commits `dca37915..d55c6adb` via `archive/pre-sync-main`; -produces one PR into `sealed-fork` touching `implementations/cli/src/commands/mint.ts`, -`packages/core/src/identity.ts`, their smokes, root `package.json`. +Interfaces: consumes new `sealed-fork` + the 7 commits `dca37915^..d55c6adb` (inclusive of +`dca37915`) via `archive/pre-sync-main`; produces one PR into `sealed-fork` touching +`implementations/cli/src/commands/mint.ts`, `packages/core/src/identity.ts`, their smokes, root +`package.json`. Verify: ported mint regression tests green; `pnpm build` green; manual smoke: `cotal mint` twice → second run reuses identity; `--force` overwrites; `--signer` mode still works. @@ -231,89 +250,65 @@ Interfaces: consumes T3 complete + T1's notice; produces each branch either reba Verify: `git branch -r --no-merged origin/sealed-fork` contains only live, rebased branches; no branch still bases on `0106d35c` ancestry. -### T9 — Post-migration: PR #13 disposition + branch hygiene -Owner: coordinating agent (with Matt's OQ-1/OQ-3 answers). -Do: close PR #13 per OQ-1 answer (its content is fully contained in T4's source); delete or archive -the superseded connector/reconnect branches once T4/T6 merge (`upstream-cotal-181-omp-connector`, +### T9 — Post-migration: PR #13/#14 disposition + branch hygiene +Owner: coordinating agent. +Do: **close PR #13** (Decision D1 — its content is fully contained in T4's source), with a +cross-link to T4's PR. **Close or retarget PR #14** (this record's review vehicle): T2 orphans its +`main` base, so once the design re-lands as the first post-reset reapply-era PR targeting +`sealed-fork` (Decision D5), close #14 with a pointer to that PR. Delete or archive the superseded +connector/reconnect branches once T4/T6 merge (`upstream-cotal-181-omp-connector`, `zheng-connector-oh-my-pi`, `zheng-connector-upstream`, `upstream-cotal-reconnect-logger`); confirm the archive tags remain. -Interfaces: consumes T4/T6 merged + OQ answers; produces a clean origin branch list. -Verify: PR #13 closed with a pointer to the T4 PR; `git branch -r` shows no stale connector/reconnect -branches; `git ls-remote origin 'refs/tags/archive/*'` still shows both tags. +Interfaces: consumes T4/T6 merged + the design re-landed on `sealed-fork`; produces a clean origin +branch list. +Verify: PR #13 and PR #14 both closed with pointers to their successor PRs; `git branch -r` shows no +stale connector/reconnect branches; `git ls-remote origin 'refs/tags/archive/*'` still shows both +tags. ## Tasks -- [ ] T1 — Freeze notice acknowledged by all in-flight owners + this record's PR authored (merge deferred to post-T3) +- [ ] T1 — Freeze notice acknowledged by all in-flight owners + PR #14 authored as review vehicle (not merged to `main`) - [ ] T2 — (HUMAN) archive tags pushed (0a/0b) + `main` force-moved to `8571c1bb`, verified 0/0 vs upstream -- [ ] T3 — (HUMAN) `sealed-fork` reset to new `main`, verified -- [ ] T4 — Connector reapply PR merged (build green — InboxTurn ported per OQ-7 — + connector smokes green) +- [ ] T3 — (HUMAN) `sealed-fork` reset to new `main`, verified; then set `trunk()=sealed-fork@origin` (D8) +- [ ] T4 — Connector reapply PR merged (build green — InboxTurn ported onto upstream pi's ledger — + connector smokes re-derived green) - [ ] T5 — Zellij reapply PR merged (build + zellij smoke green) - [ ] T6 — KV-watch + reconnect-backoff port PR merged (red-green smoke evidence attached) - [ ] T7 — Mint identity-reuse port PR merged (regression tests green; `--signer` preserved) - [ ] T8 — All in-flight branches rebased or closed-as-superseded -- [ ] T9 — PR #13 closed; stale connector/reconnect branches removed +- [ ] T9 — PR #13 + PR #14 closed; stale connector/reconnect branches removed -## Open Questions +## Decisions -All are load-bearing; each records the assumption this design was written against and a -recommendation. Matt rules on all of them before the freeze. +Ruled by Matt on the design PR (#14). These freeze on merge and become the contract executing +agents read. -1. **PR #13 disposition** — it's a byte-identical duplicate of `sealed-fork`'s connector on a stale - base (`git diff --quiet` clean on `extensions/connector-oh-my-pi/`). Close it, or keep as - reference until the reapply PR exists? - *Recommendation:* close it once T4's PR is open, with a cross-link. - *Designed against:* PR #13 closes (T9); T4 sources from `archive/pre-sync-sealed-fork`@`0106d35c`, - which contains everything PR #13 has. -2. **Reapply-set curation** — reapply the curated subset above, or more/less? In particular the drop - list: `yaml` workarounds (upstream declares `yaml ^2.9.0`), `@cotal-ai/pi` (`89a57ed5` — a side - branch NOT in upstream's history; upstream's `extensions/pi` is a same-named parallel - reimplementation rooted at `6f74fb8a` that supersedes ours functionally), and - `upstream-cotal-reconnect-logger` (subsumed by `fe6d3ec1` in T6). - *Recommendation:* the curated set — reapply connector, zellij, mint, KV-watch(+backoff); drop the - three listed. - *Designed against:* the curated set (T4–T7); the three drops. -3. **Connector reconciliation with zheng** — my hardened connector (zheng's + ~593 lines of - race-hardening; byte-identical to `sealed-fork`'s) vs zheng's on-newer-base branch: which lineage - reapplies, and who owns T4? - *Recommendation:* reapply the `sealed-fork` (hardened) lineage — it is a strict superset — and - assign T4 to the hardening author (best placed to resolve the InboxTurn port). - *Designed against:* T4 sources the hardened `sealed-fork` lineage; ownership left to Matt. -4. **Execution ownership of the main-move** — T2/T3 are the human gate (agent cannot push `main` or - tags). Who executes each step, and is this a solo lane or a manager-coordinated fleet effort - (given T8's in-flight branches)? - *Recommendation:* Matt runs T2/T3 (commands given verbatim above); a coordinating agent drives - T1/T8/T9 and the reapply lanes run as a small fleet. - *Designed against:* Matt executes T2/T3; fleet-coordinated reapply. -5. **Where do sealed-only artifacts live** so a `sealed-fork` reset doesn't wipe them — e.g. THIS - design record, which the reset orphans if it merges to old `sealed-fork`? - *Recommendation:* do NOT merge this record's PR before T3; land it as (part of) the first - post-reset reapply-era PR, making `docs/designs/` part of the new sealed delta. Never on `main`. - *Designed against:* T1 authors the PR but defers its merge to post-T3. -6. **`sealed-fork` reset = force-moving a shared branch** other lanes base on. Hard reset (owners pay - one rebase each, T8), or the rename-cutover variant (identical end state, no force-push through - branch protection, GitHub retargets open PRs)? - *Recommendation:* hard reset — a 723-commit merge is unreviewable and forfeits the - `sealed-fork − main = reviewed sealed delta` invariant; the archive tag keeps old history - reachable and T1's notice bounds the surprise. Rename-cutover is an acceptable execution variant. - *Designed against:* hard reset with T1 notice + T8 per-owner rebase/close. -7. **Connector InboxTurn strategy (the re-priced headline)** — the connector's `loop.ts:1` imports - `InboxTurn` from `@cotal-ai/connector-core`, but upstream deleted that seam (`inbox-turn.ts` gone, - `ackInbox` gone, drains via `drainInboxIds` + a two-site ack model) and re-landed `InboxTurn` in - `extensions/pi` with a **different contract** (tombstone ledger, exported at `index.ts:4` for SDK - embedders). Two ports: - - **(a) Resurrect the old seam** — re-add sealed's `inbox-turn.ts` + `ackInbox` to upstream's - rewritten `connector-core`. Risk: bolts a parallel ack surface onto a MeshAgent whose ack model - upstream redesigned (focus-ingest acks, exact-id drains) — a semantic conflict `pnpm build` will - NOT flag, surfacing later as double-surfaced/lost mesh messages, and the existing smokes fake the - OLD contract so they pass green anyway. - - **(b) Migrate onto upstream pi's exported ledger** — rewrite `loop.ts` against - `@cotal-ai/pi`'s `InboxTurn` (`extensions/pi/src/index.ts:4`, which exists FOR this use case). - Composes cleanly with "drop our `@cotal-ai/pi`" + "keep upstream's `extensions/pi`", dissolves - most of `ae2de4e1`'s re-derivation, and folds in the launcher alignment. Cost: mapping - `loop.ts`'s commit/abandon/extend calls onto the tombstone-ledger API (`CommitResult`, - `drainInboxIds`) — a real but bounded rewrite, and the smokes must be re-derived to the ledger - contract. - *Recommendation:* **(b)** — it is the composing option and avoids reintroducing a deleted seam - against a redesigned ack model. - *Designed against:* (b); T4 consumes upstream pi's exported `InboxTurn`, smokes re-derived to the - ledger. If Matt rules (a), T4 re-adds the seam and the smokes stay as-is. +- **D1 — PR #13: close now as superseded.** Its connector source is byte-identical to `sealed-fork`'s + (a duplicate reapply on stale `main`); T4 reapplies the same connector from the hardened lineage. + Close with a cross-link to the fork-sync plan / T4 PR (T9). +- **D2 — Reapply the curated set as designed.** Reapply the 4 groups (connector, zellij, + KV-watch+backoff, mint); drop the 3 superseded (`@cotal-ai/pi`, `yaml` workarounds, + `upstream-cotal-reconnect-logger`). +- **D3 — Connector lineage + owner:** reapply the **hardened** `sealed-fork` lineage (a strict + superset of zheng's); `upstream-cotal/service-owner` owns T4 (the hardening author, best placed + for the InboxTurn port). +- **D4 — Execution: agent runs the main-move.** `upstream-cotal/service-owner` runs T2/T3 (the + verbatim commands above); a coordinating agent drives T1/T8/T9; reapply lanes run as a small fleet. + (This is inside the human gate only in the push-guard sense — Matt delegated the force-push + execution to this agent explicitly; the agent does not self-authorize a `main` push absent that + ruling.) +- **D5 — Sealed artifacts land on post-reset `sealed-fork`, never `main`.** This design record's + PR #14 is a review vehicle on `main`; it does not merge to `main`. Post-reset, the record re-lands + as (part of) the first reapply-era PR targeting `sealed-fork`; #14 is closed at T9. +- **D6 — Hard reset (force-move) + archive tags.** A 723-commit merge is unreviewable and forfeits + the `sealed-fork − main = reviewed delta` invariant; the archive tags keep old history reachable + and T1's notice bounds the surprise. The rename-cutover variant is an acceptable execution detail. +- **D7 — Connector InboxTurn: migrate onto upstream pi's exported ledger (option b).** Rewrite + `loop.ts` against `@cotal-ai/pi`'s exported `InboxTurn` (`extensions/pi/src/index.ts:4`), NOT + resurrect the deleted `connector-core` seam. This composes with dropping our `@cotal-ai/pi` + + keeping upstream's, folds in the launcher alignment, and avoids a parallel ack surface against + upstream's redesigned ack model. Smokes are re-derived to the ledger contract (T4). +- **D8 — Reapply PRs base on `sealed-fork` via `trunk()`.** jj-vine cannot base a PR on a non-trunk + `sealed-fork` (hangs untracked / panics tracked, `bookmark.rs:738`; no `--base` flag). Post-T3, + set the clone's `revset-alias."trunk()" = "sealed-fork@origin"` so jj-vine bases every reapply PR + on `sealed-fork` natively. (A jj-vine follow-up: the hang/panic on a non-trunk base is a tool + defect worth filing so future non-trunk bases don't need the trunk-alias workaround.) From 2a71a24bc4b9babd7c1b2365f6eb5e184e02eb75 Mon Sep 17 00:00:00 2001 From: seal Date: Mon, 17 Aug 2026 14:10:04 -0400 Subject: [PATCH 9/9] docs(platform): rename SEA- tracker refs to RIG- in fork-sync record Fleet directive (Matt): the Linear team renamed SEA- -> RIG- (numbers preserved, pure prefix swap; workspace now linear.app/rigelbuild). Applied forward-looking to this open, not-yet-merged design record so the reapply lanes (T4-T7) resolve live Linear ids: - KV-watch-leak tracker id SEA-1821 -> RIG-1821 (inventory row + T6 heading). - T6's prescribed NEW branch reapply-sea1821-kv-watch -> reapply-rig1821-kv-watch (new branches take the rig- prefix). Left as-is per the directive's already-pushed-branch carve-out: the live freeze-list branch harness-sea1821-kv-watch-leak (jj-vine tracks it by name; renaming a live PR branch is out of scope). Refs RIG-1821 Co-authored-by: Matt Wilkinson --- docs/designs/platform/cotal-fork-sync.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/designs/platform/cotal-fork-sync.md b/docs/designs/platform/cotal-fork-sync.md index 6f6d705efe..e306a7b532 100644 --- a/docs/designs/platform/cotal-fork-sync.md +++ b/docs/designs/platform/cotal-fork-sync.md @@ -46,7 +46,7 @@ Curated inventory (verified against the clone this session, re-confirmed by the | oh-my-pi connector | `55bc0c60` (feat) + 12 fix/test commits through `568c8175`, incl. `e2347788` (pi-coding-agent 16.3.12 + zod), `ae2de4e1` (connector-core InboxTurn), `f6f91657` (CI smokes), `cd091f1b` (`@cotal-ai/delivery` decl) | **Reapply as a semantic PORT (not a tree-copy) — see Decision D7.** Upstream has no `extensions/connector-oh-my-pi` (`git ls-tree upstream/main extensions/` — only cmux, connector-{claude-code,codex,core,hermes,opencode}, orca, pi, tmux), so the tree lands with no path collision, and its `src/` is byte-identical to PR #13's branch (`git diff --quiet origin/sealed-fork origin/upstream-cotal-181-omp-connector -- extensions/connector-oh-my-pi/` is clean over the whole extension). **But byte-identity only proves our two copies match each other, not that the tree composes with upstream.** It does not: `loop.ts:1` **value-imports** `InboxTurn` (and `loop.ts:2` type-imports `InboxItem`/`InboxSource`) from `@cotal-ai/connector-core` — the value import is the build-breaker. Upstream deleted `extensions/connector-core/src/inbox-turn.ts` (`git cat-file -e` fails) and has no `ackInbox` (it drains via `drainInboxIds`, `extensions/connector-core/src/agent.ts:462`, with a two-site ack model). Upstream re-landed `InboxTurn` inside `extensions/pi` with a **different contract** (tombstone ledger, `TOMBSTONE_CAP=4096`, `extensions/pi/src/inbox-turn.ts:14,21`), exported at `extensions/pi/src/index.ts:4` "for SDK embedders driving their own session". `pnpm build` fails at `loop.ts:1` until the connector is ported per D7. | | `@cotal-ai/zellij` runtime + placement | `35c26085`, `48c084b8` (feats) + 10 hardening commits through `a6ecda6b` | **Reapply.** Upstream has no `extensions/zellij` and no zellij references in `implementations/cli/src/` (grep clean); upstream's `extensions/tmux` is the sibling pattern to re-anchor the CLI-allowlist commits (`f2d0ba27`) against. | | cotal-mint identity-reuse | The 7 commits on `origin/main` only (`dca37915^..d55c6adb`, PR #9) — **not** on `sealed-fork` (`merge-base --is-ancestor d55c6adb origin/sealed-fork` fails). Note the `^` — `dca37915..d55c6adb` (two-dot, exclusive) drops `dca37915` itself (the foundational reuse commit) and yields only 6; the inclusive set is `dca37915^..d55c6adb` = 7. | **Reapply, expect conflicts.** Upstream `implementations/cli/src/commands/mint.ts` is 115 lines vs our 158 and has no identity-reuse (`grep 'reuse'` clean); its `--force` overwrite guard (`mint.ts:37-38`) sits inside a **new `--signer` mode** (`values.signer`, `mint.ts:29-42`) that did not exist in our version — the re-derivation must preserve it. Source from the archive tag, not the `cotal-mint-reuse-identity` branch (that branch carries only 4 of the 7 commits — see T7). | -| KV-watch-leak fix (SEA-1821) | `5e9a274f` + review rounds `b99340c5`, `c21270d8` (PR #12) | **Reapply, re-derive.** Upstream `packages/core/src/endpoint.ts` (3666 lines) has **no** `presenceWatch`/`channelWatch`/`stopWatch` (grep clean); the sealed fix lives at `origin/sealed-fork:packages/core/src/endpoint.ts:234,379-382`. The file drifted heavily (reconnect machinery now at `endpoint.ts:375-391`), so this is a port, not a clean cherry-pick. The smoke `packages/core/smoke/reconnect-watch-leak.smoke.ts` ports with it. **This lane also absorbs `fe6d3ec1`'s `packages/core` half** (exponential reconnect backoff) so all `endpoint.ts` reconnect surgery lands in one lane (see T6). | +| KV-watch-leak fix (RIG-1821) | `5e9a274f` + review rounds `b99340c5`, `c21270d8` (PR #12) | **Reapply, re-derive.** Upstream `packages/core/src/endpoint.ts` (3666 lines) has **no** `presenceWatch`/`channelWatch`/`stopWatch` (grep clean); the sealed fix lives at `origin/sealed-fork:packages/core/src/endpoint.ts:234,379-382`. The file drifted heavily (reconnect machinery now at `endpoint.ts:375-391`), so this is a port, not a clean cherry-pick. The smoke `packages/core/smoke/reconnect-watch-leak.smoke.ts` ports with it. **This lane also absorbs `fe6d3ec1`'s `packages/core` half** (exponential reconnect backoff) so all `endpoint.ts` reconnect surgery lands in one lane (see T6). | | reconnect-logger (`origin/upstream-cotal-reconnect-logger`, tip `0041740f`, unmerged — 1 ahead of `sealed-fork`) | Standalone upstream-shaped variant of `fe6d3ec1` (edge logging, injectable MeshAgent logger, exponential endpoint backoff) | **Drop — superseded.** Its content is subsumed by `fe6d3ec1`, folded into T6's `endpoint.ts` port. Listed here so it is neither lost nor left basing on orphaned history; freeze-noticed in T1 and cleaned up in T9. | | `@cotal-ai/pi` (`89a57ed5`) | On `origin/zheng-connector-oh-my-pi`, **not** on `sealed-fork` | **Drop — superseded by upstream's parallel `extensions/pi`.** `89a57ed5` is **not** an ancestor of `upstream/main` (`git merge-base --is-ancestor 89a57ed5 upstream/main` FAILS; it lives only on the side branch `upstream/feat/connector-openai-vercel` + `zheng-connector-oh-my-pi`). Upstream's `extensions/pi` is a **parallel reimplementation** with the same package name, rooted at a separate commit (`6f74fb8a`), evolved +1879/−269 over 15 files past our ancestor — **not** our `89a57ed5` evolved. It supersedes ours functionally and launches via `command: "pi"` (`extensions/pi/src/connector.ts:112`). We keep upstream's `extensions/pi` (untouched) and, per D7, the connector consumes its exported `InboxTurn`. | | `yaml` phantom-dep workarounds | folded inside connector commits | **Drop.** Upstream declares `yaml ^2.9.0` (`packages/core/package.json:52`); any sealed workaround for the headless-spawn breakage is dead weight on the new base. | @@ -195,9 +195,9 @@ one PR into `sealed-fork` adding `extensions/zellij` + CLI allowlist + CI cachin Verify: `pnpm build` green; `extensions/zellij/smoke.ts` passes (in a throwaway zellij session per `rule://zellij-session-safety`); `--runtime zellij` accepted by the CLI override allow-lists. -### T6 — Reapply lane: KV-watch-leak fix (SEA-1821) + reconnect backoff +### T6 — Reapply lane: KV-watch-leak fix (RIG-1821) + reconnect backoff Owner: the `harness-sea1821-kv-watch-leak` lane owner. -Do: branch `reapply-sea1821-kv-watch` off new `sealed-fork`; **port** (not cherry-pick) the fix onto +Do: branch `reapply-rig1821-kv-watch` off new `sealed-fork`; **port** (not cherry-pick) the fix onto upstream's drifted `packages/core/src/endpoint.ts` (3666 lines, no trace of the fix). **Source the fix content from `archive/pre-sync-sealed-fork`@`0106d35c`** — the SHAs `5e9a274f`, `b99340c5`, `c21270d8`, and `fe6d3ec1` are reachable ONLY from `0106d35c` and are NOT ancestors of `d55c6adb`,