From c4d4b75791c626af16f069262dbefec66b0ae8a9 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 10 Nov 2026 15:50:09 -0700 Subject: [PATCH 1/3] fix(build): replace stale worktree ownership records Treat structurally valid ownership records for a different checkout identity as unowned. Preserve malformed-record rejection, publication recovery guards, and unknown output collisions. Carry-Group: build-ci Carry-Observation: Stale native identities grant no output replacement or deletion authority; successful staged publication records fresh ownership. --- .../src/generatedBuildOwnership.test.ts | 90 +++++++++++++++---- .../shared/src/generatedBuildOwnership.ts | 13 ++- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/generatedBuildOwnership.test.ts b/packages/shared/src/generatedBuildOwnership.test.ts index e6e239a40594c..207c7dd2266b2 100644 --- a/packages/shared/src/generatedBuildOwnership.test.ts +++ b/packages/shared/src/generatedBuildOwnership.test.ts @@ -81,24 +81,70 @@ it("reclaims unchanged staged emissions, retains additions/modified files, and r ); }); -it("never claims preexisting matching output or overwrites unknown collisions", () => { - const { cwd, emit } = fixture(); - const root = "apps/server/dist"; - emit(NodePath.join(cwd, root), "same.js", "same emission"); - emit(NodePath.join(cwd, root), "notes.js", "unknown addition"); - const stage = createGeneratedBuildStage(cwd, root)!; - emit(stage.directory, "same.js", "same emission"); - stage.publish(100); - assert.deepEqual(inspectGeneratedBuildOwnership(cwd, root)!.files, []); - const collision = createGeneratedBuildStage(cwd, root)!; - emit(collision.directory, "notes.js", "different emission"); - assert.throws(() => collision.publish(200), "requires file review"); - collision.discard(); - assert.equal( - NodeFS.readFileSync(NodePath.join(cwd, root, "notes.js"), "utf8"), - "unknown addition", - ); -}); +it.each(["absent", "device", "inode", "birthtime", "path"] as const)( + "never claims preexisting matching output or overwrites unknown collisions (prior owner: %s)", + (identityChange) => { + const { cwd, metadata, emit } = fixture(); + const root = "apps/server/dist"; + if (identityChange !== "absent") { + const prior = createGeneratedBuildStage(cwd, root)!; + emit(prior.directory, "same.js", "same emission"); + emit(prior.directory, "notes.js", "unknown addition"); + emit(prior.directory, "obsolete.js", "prior output"); + prior.publish(50); + const previous = inspectGeneratedBuildOwnership(cwd, root)!; + const recordPath = NodePath.join( + metadata, + NodeFS.readdirSync(metadata).find( + (file) => file.startsWith("t3-build-") && file.endsWith(".json"), + )!, + ); + const record = JSON.parse(NodeFS.readFileSync(recordPath, "utf8")); + const stat = NodeFS.lstatSync(cwd); + const allocation = [stat.dev, stat.ino, stat.birthtimeMs]; + if (identityChange !== "path") { + const index = { device: 0, inode: 1, birthtime: 2 }[identityChange]; + allocation[index]! += 1; + } + record.worktreeIdentity = `${identityChange === "path" ? `${cwd}-prior` : cwd}:${JSON.stringify(allocation)}`; + const stale = JSON.stringify(record); + NodeFS.writeFileSync(recordPath, stale); + assert.isNull(inspectGeneratedBuildOwnership(cwd, root)); + assert.isFalse(recordGeneratedBuildUse(cwd, root, 75)); + assert.equal(NodeFS.readFileSync(recordPath, "utf8"), stale); + assert.throws( + () => removeGeneratedBuildFiles(cwd, root, previous.token, ["same.js"]), + "ownership changed", + ); + } + emit(NodePath.join(cwd, root), "same.js", "same emission"); + emit(NodePath.join(cwd, root), "notes.js", "unknown addition"); + const stage = createGeneratedBuildStage(cwd, root)!; + emit(stage.directory, "same.js", "same emission"); + emit(stage.directory, "fresh.js", "fresh emission"); + stage.publish(100); + const after = inspectGeneratedBuildOwnership(cwd, root)!; + assert.equal(after.lastUsedAt, 100); + assert.deepEqual( + after.files.map((file) => file.path), + ["fresh.js"], + ); + if (identityChange !== "absent") { + assert.equal( + NodeFS.readFileSync(NodePath.join(cwd, root, "obsolete.js"), "utf8"), + "prior output", + ); + } + const collision = createGeneratedBuildStage(cwd, root)!; + emit(collision.directory, "notes.js", "different emission"); + assert.throws(() => collision.publish(200), "requires file review"); + collision.discard(); + assert.equal( + NodeFS.readFileSync(NodePath.join(cwd, root, "notes.js"), "utf8"), + "unknown addition", + ); + }, +); it("rejects linked or hardlinked destinations and forged roots; ordinary checkout builds keep their behavior", () => { const { cwd, temporary, emit } = fixture(); @@ -180,6 +226,14 @@ it("positive compiled-output use renews tokens, while malformed provenance is ne const record = NodeFS.readdirSync(metadata).find( (file) => file.startsWith("t3-build-") && file.endsWith(".json"), )!; + const recordPath = NodePath.join(metadata, record); + const malformed = JSON.parse(NodeFS.readFileSync(recordPath, "utf8")); + malformed.worktreeIdentity = `${cwd}:stale`; + malformed.files[0].path = "../retained"; + const invalid = JSON.stringify(malformed); + NodeFS.writeFileSync(recordPath, invalid); + assert.throws(() => createGeneratedBuildStage(cwd, root), "Invalid owned build file"); + assert.equal(NodeFS.readFileSync(recordPath, "utf8"), invalid); NodeFS.writeFileSync(NodePath.join(metadata, record), "invalid record"); assert.throws(() => recordGeneratedBuildUse(cwd, root, 300)); assert.equal(NodeFS.readFileSync(NodePath.join(metadata, record), "utf8"), "invalid record"); diff --git a/packages/shared/src/generatedBuildOwnership.ts b/packages/shared/src/generatedBuildOwnership.ts index 5e1f1a9ec374e..6a889bce36a2d 100644 --- a/packages/shared/src/generatedBuildOwnership.ts +++ b/packages/shared/src/generatedBuildOwnership.ts @@ -187,23 +187,30 @@ function readRecord(ctx: Context, allowPending = false) { throw new Error("Unsafe build ownership record"); const raw = NodeFS.readFileSync(ctx.recordPath, "utf8"); const value: unknown = JSON.parse(raw); - validateRecord(ctx, value); + validateRecord(ctx, value, false); if (signature(NodeFS.lstatSync(ctx.recordPath)) !== signature(stat)) throw new Error("Build ownership changed during observation"); + // Native identities can go stale across boots; stale provenance grants no ownership. + if (value.worktreeIdentity !== ctx.worktreeIdentity) return null; return { record: value as BuildRecord, recordIdentity: signature(stat), token: NodeCrypto.createHash("sha256").update(raw).update(signature(stat)).digest("hex"), }; } -function validateRecord(ctx: Context, value: unknown): asserts value is BuildRecord { +function validateRecord( + ctx: Context, + value: unknown, + requireCurrentOwner = true, +): asserts value is BuildRecord { if ( typeof value !== "object" || value === null || !("version" in value) || value.version !== 1 || !("worktreeIdentity" in value) || - value.worktreeIdentity !== ctx.worktreeIdentity || + typeof value.worktreeIdentity !== "string" || + (requireCurrentOwner && value.worktreeIdentity !== ctx.worktreeIdentity) || !("root" in value) || value.root !== ctx.root || !("lastUsedAt" in value) || From c57a4f5a4ce44a166b4951f8beed8aca9b767041 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 10 Nov 2026 15:58:53 -0700 Subject: [PATCH 2/3] fix(build): preserve validation and review boundaries for stale owners Validate saved owner identity structure before treating a mismatch as unowned. Ignore stale observation-only review clocks without weakening protected review markers, and explain how to handle changed unowned output. Carry-Group: build-ci Carry-Observation: Compiler records that become legacy after identity changes must not create permanent review protection from observation-only clocks. --- .../src/generatedBuildOwnership.test.ts | 24 ++++++++- .../shared/src/generatedBuildOwnership.ts | 31 ++++++++++-- .../shared/src/generatedBuildReview.test.ts | 49 +++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/generatedBuildOwnership.test.ts b/packages/shared/src/generatedBuildOwnership.test.ts index 207c7dd2266b2..38c9f63ef57d5 100644 --- a/packages/shared/src/generatedBuildOwnership.test.ts +++ b/packages/shared/src/generatedBuildOwnership.test.ts @@ -116,6 +116,15 @@ it.each(["absent", "device", "inode", "birthtime", "path"] as const)( () => removeGeneratedBuildFiles(cwd, root, previous.token, ["same.js"]), "ownership changed", ); + const changed = createGeneratedBuildStage(cwd, root)!; + emit(changed.directory, "same.js", "changed compiler emission"); + assert.throws(() => changed.publish(75), "requires file review"); + changed.discard(); + assert.equal( + NodeFS.readFileSync(NodePath.join(cwd, root, "same.js"), "utf8"), + "same emission", + ); + assert.equal(NodeFS.readFileSync(recordPath, "utf8"), stale); } emit(NodePath.join(cwd, root), "same.js", "same emission"); emit(NodePath.join(cwd, root), "notes.js", "unknown addition"); @@ -228,7 +237,20 @@ it("positive compiled-output use renews tokens, while malformed provenance is ne )!; const recordPath = NodePath.join(metadata, record); const malformed = JSON.parse(NodeFS.readFileSync(recordPath, "utf8")); - malformed.worktreeIdentity = `${cwd}:stale`; + for (const identity of [ + "not-an-identity", + `${cwd}:[1,2]`, + `${cwd}:[1,"2",3]`, + `${cwd}:[1,2,null]`, + `${cwd}:[1,2,3,4]`, + "relative:[1,2,3]", + ]) { + const invalid = JSON.stringify({ ...malformed, worktreeIdentity: identity }); + NodeFS.writeFileSync(recordPath, invalid); + assert.throws(() => createGeneratedBuildStage(cwd, root), "Invalid build ownership record"); + assert.equal(NodeFS.readFileSync(recordPath, "utf8"), invalid); + } + malformed.worktreeIdentity = malformed.worktreeIdentity.replace(cwd, `${cwd}-prior`); malformed.files[0].path = "../retained"; const invalid = JSON.stringify(malformed); NodeFS.writeFileSync(recordPath, invalid); diff --git a/packages/shared/src/generatedBuildOwnership.ts b/packages/shared/src/generatedBuildOwnership.ts index 6a889bce36a2d..0d25e40787ba1 100644 --- a/packages/shared/src/generatedBuildOwnership.ts +++ b/packages/shared/src/generatedBuildOwnership.ts @@ -31,6 +31,22 @@ const safeRelative = (path: string) => !NodePath.posix.isAbsolute(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +function validWorktreeIdentity(value: unknown): value is string { + if (typeof value !== "string") return false; + const separator = value.lastIndexOf(":"); + if (separator < 1 || !NodePath.isAbsolute(value.slice(0, separator))) return false; + try { + const identity: unknown = JSON.parse(value.slice(separator + 1)); + return ( + Array.isArray(identity) && + identity.length === 3 && + identity.every((part) => typeof part === "number" && Number.isFinite(part)) + ); + } catch { + return false; + } +} + function context(worktreePath: string, root: string) { if (!allowedRoot(root)) throw new Error("Unrecognized generated build root"); const cwd = NodeFS.realpathSync(worktreePath); @@ -209,7 +225,7 @@ function validateRecord( !("version" in value) || value.version !== 1 || !("worktreeIdentity" in value) || - typeof value.worktreeIdentity !== "string" || + !validWorktreeIdentity(value.worktreeIdentity) || (requireCurrentOwner && value.worktreeIdentity !== ctx.worktreeIdentity) || !("root" in value) || value.root !== ctx.root || @@ -1076,7 +1092,9 @@ export function createGeneratedBuildStage(worktreePath: string, root: string) { return { file, destination, prior, skip: false }; if (existing.hash === file.hash) return { file, destination, prior: undefined, skip: true }; - throw new Error(`Build output collision requires file review: ${root}/${file.path}`); + throw new Error( + `Build output collision requires file review: ${root}/${file.path}. The existing file differs from the new build output and no current build owns it, so it was left in place. Review it or move it aside, then rebuild.`, + ); } catch (error) { if (missing(error)) return { file, destination, prior: undefined, skip: false }; throw error; @@ -1228,7 +1246,7 @@ function rootAllocation(ctx: Context) { return allocation(stat); } -function readReview(ctx: Context): ReviewSnapshot | null { +function readReview(ctx: Context, includeStaleObservation = false): ReviewSnapshot | null { const path = reviewPath(ctx); let fd; try { @@ -1247,7 +1265,8 @@ function readReview(ctx: Context): ReviewSnapshot | null { typeof value !== "object" || value === null || value.version !== 1 || - value.worktreeIdentity !== ctx.worktreeIdentity || + !validWorktreeIdentity(value.worktreeIdentity) || + (value.protected && value.worktreeIdentity !== ctx.worktreeIdentity) || value.root !== ctx.root || typeof value.rootAllocation !== "string" || typeof value.lastUsedAt !== "number" || @@ -1284,6 +1303,8 @@ function readReview(ctx: Context): ReviewSnapshot | null { signature(NodeFS.fstatSync(fd)) !== signature(stat) ) throw new Error("Build review changed during observation"); + // Observation-only clocks hold no consent; retained protection still binds to its owner. + if (value.worktreeIdentity !== ctx.worktreeIdentity && !includeStaleObservation) return null; return { record: value as ReviewRecord, token: digest(raw + signature(stat)), @@ -1421,7 +1442,7 @@ export function generatedBuildReviewProtection(worktreePath: string, relativeCan digest(value.root) !== match[1] ) return true; - const previous = readReview({ ...ctx, root: value.root }); + const previous = readReview({ ...ctx, root: value.root }, true); if (!previous) return true; if (!previous.record.protected) continue; const root = previous.record.root; diff --git a/packages/shared/src/generatedBuildReview.test.ts b/packages/shared/src/generatedBuildReview.test.ts index 5e916e99c9c0b..051dd4cae9eea 100644 --- a/packages/shared/src/generatedBuildReview.test.ts +++ b/packages/shared/src/generatedBuildReview.test.ts @@ -75,6 +75,51 @@ it("starts a conservative first-observation clock, never adopts mtimes or compil assert.isFalse(generatedBuildReviewProtection(cwd, "node_modules")); }); +it.each(["observe", "rebuild"] as const)( + "stale compiler observations retain no consent across another identity change (%s)", + (operation) => { + const { cwd, metadata, output, receipt } = fixture(); + const compiler = createGeneratedBuildStage(cwd, root)!; + NodeFS.writeFileSync(NodePath.join(compiler.directory, "owned.js"), "prior compiler output"); + compiler.publish(100); + const recordPath = NodePath.join( + metadata, + NodeFS.readdirSync(metadata).find( + (name) => name.startsWith("t3-build-") && name.endsWith(".json"), + )!, + ); + const record = JSON.parse(NodeFS.readFileSync(recordPath, "utf8")); + const stat = NodeFS.lstatSync(cwd); + record.worktreeIdentity = `${cwd}:${JSON.stringify([stat.dev + 1, stat.ino, stat.birthtimeMs])}`; + NodeFS.writeFileSync(recordPath, JSON.stringify(record)); + const observation = observeLegacyBuild(cwd, root, 200)!; + assert.isFalse(generatedBuildReviewProtection(cwd)); + + const reviewPath = receipt(); + const review = JSON.parse(NodeFS.readFileSync(reviewPath, "utf8")); + review.worktreeIdentity = `${cwd}:${JSON.stringify([stat.dev + 2, stat.ino, stat.birthtimeMs])}`; + NodeFS.writeFileSync(reviewPath, JSON.stringify(review)); + assert.isFalse(generatedBuildReviewProtection(cwd)); + assert.isNull(legacyBuildActivity(cwd, root)); + if (operation === "observe") { + const refreshed = observeLegacyBuild(cwd, root, 300)!; + assert.equal(refreshed.lastUsedAt, 300); + assert.notEqual(refreshed.token, observation.token); + assert.isFalse(refreshed.hasSelection); + } + const rebuilt = createGeneratedBuildStage(cwd, root)!; + NodeFS.writeFileSync(NodePath.join(rebuilt.directory, "fresh.js"), "fresh compiler output"); + rebuilt.publish(400); + const owned = inspectGeneratedBuildOwnership(cwd, root)!; + assert.equal(removeGeneratedBuildFiles(cwd, root, owned.token, ["fresh.js"]).files, 1); + assert.equal( + NodeFS.readFileSync(NodePath.join(output, "owned.js"), "utf8"), + "prior compiler output", + ); + assert.isFalse(generatedBuildReviewProtection(cwd)); + }, +); + it("removes only unchanged selected files once and preserves changed, recreated, new and unselected files", async () => { const { cwd, emit, output, approve } = fixture(); emit("edited.js"); @@ -179,6 +224,10 @@ it("malformed, unreadable and mismatched markers fail closed without any survivi valid.replace(/"worktreeIdentity":"[^"]+"/, '"worktreeIdentity":"replacement"'), ); assert.isTrue(generatedBuildReviewProtection(cwd, "node_modules")); + const stale = JSON.parse(valid); + stale.worktreeIdentity = stale.worktreeIdentity.replace(cwd, `${cwd}-prior`); + NodeFS.writeFileSync(path, JSON.stringify(stale)); + assert.isTrue(generatedBuildReviewProtection(cwd)); }); it("context changes and positive actual use revoke authority while preview/save do not renew age", async () => { From ed6e5e5c18cf5aefc1a99f599f68b3d1ab920f89 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 10 Nov 2026 16:04:23 -0700 Subject: [PATCH 3/3] fix(build): clarify output collision ownership Describe ownership of the existing bytes accurately when output was edited after emission. Carry-Group: build-ci --- packages/shared/src/generatedBuildOwnership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/generatedBuildOwnership.ts b/packages/shared/src/generatedBuildOwnership.ts index 0d25e40787ba1..96ecf151d0425 100644 --- a/packages/shared/src/generatedBuildOwnership.ts +++ b/packages/shared/src/generatedBuildOwnership.ts @@ -1093,7 +1093,7 @@ export function createGeneratedBuildStage(worktreePath: string, root: string) { if (existing.hash === file.hash) return { file, destination, prior: undefined, skip: true }; throw new Error( - `Build output collision requires file review: ${root}/${file.path}. The existing file differs from the new build output and no current build owns it, so it was left in place. Review it or move it aside, then rebuild.`, + `Build output collision requires file review: ${root}/${file.path}. The existing file differs from the new build output and no current build owns these bytes, so it was left in place. Review it or move it aside, then rebuild.`, ); } catch (error) { if (missing(error)) return { file, destination, prior: undefined, skip: false };