diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index 921deeb0..021bef75 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -520,6 +520,20 @@ describe("defineService", () => { ).toThrow(/procedure name/i); }); + test("a hand-built procedure is frozen too, not just builder output", () => { + // AnyProcedures accepts any ProcedureDef shape; the guarantee must not + // depend on the caller having used procedure.build(). + const contract = defineService({ + name: "demo", + procedures: { ping: { permission: "demo:read" } }, + }); + expect(Object.isFrozen(contract.procedures.ping)).toBe(true); + expect(() => { + (contract.procedures.ping as { permission?: string }).permission = "hacked"; + }).toThrow(); + expect(contract.procedures.ping.permission).toBe("demo:read"); + }); + test("the builder is immutable — reusing a base does not cross-contaminate", () => { const base = procedure.permission("a:read"); const one = base.idempotent().build(); @@ -609,7 +623,18 @@ export function defineService(def: { ); } } - return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); + // Freeze each procedure, not just the map. AnyProcedures accepts any object + // of ProcedureDef shape, so a hand-built def that never went through + // procedure.build() would otherwise stay mutable and the "single source of + // truth" guarantee would rest on every call site remembering the builder. + const frozen: Record = {}; + for (const [name, value] of Object.entries(def.procedures)) { + frozen[name] = Object.freeze({ ...value }); + } + return Object.freeze({ + name: def.name, + procedures: Object.freeze(frozen) as Procedures, + }); } ```