From e0bd84247e59510e00611bb4bdad6c4527544777 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:01:52 +0530 Subject: [PATCH] docs: deep-freeze procedures in the Task 3 plan snippet defineService froze the procedures map but not each procedure inside it, so a ProcedureDef built by hand rather than through procedure.build() stayed mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The contract is shared between two apps as a single source of truth, and the guarantee rested on every call site remembering to use the builder. Same class as the authz catalog's frozenMap, which froze the Map's mutators but not the values it handed out. Co-Authored-By: Claude Opus 5 --- ...26-08-05-inter-app-comms-implementation.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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, + }); } ```