From fc0f97367a3295fd9b78d4d30370723737d75714 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Thu, 16 Jul 2026 18:39:56 +0200 Subject: [PATCH] fix(planning): never plan a User/Auth slice the BoringStack starter already ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live: for a bookmark-manager build the planner proposed two slices, User and Bookmark. But BoringStack ships auth/users/sessions out of the box — the real login/signup code references locale keys under `auth.*`. The model, scoped to build a `features/user/**` slice, created a PARALLEL set of `features.user.errors.*` keys that were unused by construction (real usage is `auth.*`), so the i18n-locale-keys gate looped forever on "unused key" — add key (slice says build it) → gate says unused → remove → re-add. The build oscillated 80+ turns at 1 error on a slice that can never go green because it duplicates the built-in surface. Fix (defense in depth): - PLANNER_SYSTEM now states the stack already provides auth/users/sessions and must NOT propose a User/Account/Auth/Session/Login/SignUp/Profile slice — treat "a user" as an existing actor entities belong to. - Enforcement backstop: stripReservedSlices() drops any slice whose entity id is a reserved identity concept (RESERVED_ENTITY_IDS), applied to every proposePlan result. If stripping would empty the plan, the original is kept (an empty plan builds nothing — worse than one redundant slice). Tests: stripReservedSlices drops User keeps Bookmark; keeps all when every slice is reserved; proposePlan strips the exact live User+Bookmark collision to Bookmark; PLANNER_EXAMPLE proposes no reserved entity. --- .../core/src/loop/planning/propose-plan.ts | 45 +++++++++++- packages/core/tests/propose-plan.test.ts | 73 +++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/core/src/loop/planning/propose-plan.ts b/packages/core/src/loop/planning/propose-plan.ts index 7d0431de..71a33e76 100644 --- a/packages/core/src/loop/planning/propose-plan.ts +++ b/packages/core/src/loop/planning/propose-plan.ts @@ -50,6 +50,8 @@ export const PLANNER_EXAMPLE = { */ const PLANNER_SYSTEM = `You are a product architect. From the product description and any mockups, propose a domain model as feature slices (one per entity). Respond with ONLY a JSON object — no prose, no markdown fences — matching this schema EXACTLY. Use these exact key names and value shapes; do not add, rename, or nest differently. +The target stack ALREADY PROVIDES authentication, user accounts, and sessions out of the box: sign-up, log-in, log-out, the users table, and per-user ownership all exist. Do NOT propose a slice for User, Account, Auth, Session, Login, SignUp, Profile, or any authentication/identity concept — building it duplicates the built-in surface and traps the build. Treat "a user" as an existing actor that your entities belong to (via a relationship like "belongs to a User"), never as an entity to build. Propose slices ONLY for the product's own domain entities. + Schema: { "product": "", @@ -101,10 +103,45 @@ export function parsePlanJson(raw: string): IProductPlan | null { } } +/** Identity/auth concepts the BoringStack starter already ships (sign-up, log-in, + * the users table, per-user ownership). A slice for one of these duplicates the + * built-in surface and traps the build — its locale keys/routes never wire up + * because the real usage lives in the scaffold's auth feature, so the gate loops + * forever on "unused" keys. The prompt steers away from them; this set is the + * enforcement backstop. Compared lowercased. */ +export const RESERVED_ENTITY_IDS: ReadonlySet = new Set([ + "user", + "users", + "account", + "auth", + "authentication", + "session", + "login", + "signin", + "signup", + "logout", + "profile", + "credential", +]); + +/** Drop slices whose entity is a reserved identity concept the stack already + * provides. If stripping would empty the plan (a description of nothing but + * auth), keep the original — an empty plan builds nothing and signals a + * mis-scoped description, which is worse than one redundant slice. */ +export function stripReservedSlices(plan: IProductPlan): IProductPlan { + const kept = plan.slices.filter( + (slice) => !RESERVED_ENTITY_IDS.has(slice.entity.id.toLowerCase()) + ); + + return kept.length > 0 ? { ...plan, slices: kept } : plan; +} + /** * Ask the model to propose a structured product plan from a description. * Returns null when the model's response can't be parsed into a usable plan. - * Retries once at higher temperature (0 → 0.7) on parse failure. + * Retries once at higher temperature (0 → 0.7) on parse failure. Reserved + * identity slices (User/Auth/…) the stack already ships are stripped from the + * result so the build never chases a redundant, un-satisfiable slice. */ export async function proposePlan( deps: { planner: IProvider }, @@ -127,7 +164,7 @@ export async function proposePlan( const parsed1 = parsePlanJson(res1.content); if (parsed1 !== null) { - return parsed1; + return stripReservedSlices(parsed1); } // Retry: temperature 0.7 (more creative/forgiving) @@ -139,5 +176,7 @@ export async function proposePlan( { temperature: 0.7 } ); - return parsePlanJson(res2.content); + const parsed2 = parsePlanJson(res2.content); + + return parsed2 === null ? null : stripReservedSlices(parsed2); } diff --git a/packages/core/tests/propose-plan.test.ts b/packages/core/tests/propose-plan.test.ts index 783f885e..b30e14a9 100644 --- a/packages/core/tests/propose-plan.test.ts +++ b/packages/core/tests/propose-plan.test.ts @@ -2,8 +2,10 @@ import { test, expect } from "bun:test"; import { proposePlan, parsePlanJson, + stripReservedSlices, PLANNER_EXAMPLE, } from "../src/loop/planning/propose-plan"; +import type { IProductPlan } from "../src/loop/planning/plan-types"; import { isProductPlan } from "../src/loop/planning/plan-store"; import type { IProvider } from "../src/inference"; @@ -149,6 +151,77 @@ test("parsePlanJson rejects invalid plan shape", () => { expect(result).toBeNull(); }); +function userSlice(id: string) { + return { + entity: { + id, + desc: "an account", + fields: [{ name: "email", type: "string" }], + relationships: [], + rules: [], + }, + ui: { + screens: ["form"], + action: "sign up / log in", + shows: ["email"], + nav: id, + }, + verification: { + mustRemainTrue: ["auth"], + mustNotHappen: ["no email"], + acceptanceCheck: "bun test", + }, + }; +} + +test("stripReservedSlices drops an identity slice the stack ships but keeps real ones", () => { + const plan = { + ...mockPlan, + slices: [userSlice("User"), mockPlan.slices[0]], + } as IProductPlan; + + const stripped = stripReservedSlices(plan); + + expect(stripped.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("stripReservedSlices keeps the original when EVERY slice is reserved (no empty plan)", () => { + const plan = { + ...mockPlan, + slices: [userSlice("User"), userSlice("Session")], + } as IProductPlan; + + // An all-auth plan is mis-scoped, but an empty plan builds nothing — keep it. + expect(stripReservedSlices(plan).slices).toHaveLength(2); +}); + +test("proposePlan strips a redundant User slice (the live bookmark-app collision)", async () => { + // The exact failure observed live: the planner returned User + Bookmark; the + // User slice's locale keys never wired up (real auth lives in the scaffold), + // so the gate looped forever on unused keys. proposePlan must not emit it. + const planner: IProvider = { + complete: async () => ({ + content: JSON.stringify({ + ...mockPlan, + slices: [userSlice("User"), mockPlan.slices[0]], + }), + toolCalls: [], + }), + }; + + const plan = await proposePlan({ planner }, { description: "bookmarks" }); + + expect(plan?.slices.map((s) => s.entity.id)).toEqual(["Bookmark"]); +}); + +test("PLANNER_EXAMPLE proposes no reserved identity entity", () => { + // The worked example must model good behaviour: no User/Auth/Session slice. + const ids = PLANNER_EXAMPLE.slices.map((s) => s.entity.id.toLowerCase()); + + expect(ids).not.toContain("user"); + expect(ids).not.toContain("auth"); +}); + test("PLANNER_EXAMPLE (the shape shown to the model) is itself a valid plan", () => { // The prompt teaches the model by example. If a future edit breaks the // example's shape, the contract we advertise diverges from what the parser