Skip to content

Commit bb0f894

Browse files
committed
refactor(daemon): the admitted-plan token is a nominal, non-copyable object, not a branded literal
Review (P1): spreading a valid admission and overriding device or plan kept the enumerable symbol brand and stayed assignable, so facts admitted for A could bind B. The token is now a class whose payload lives in #private fields behind getter-only accessors, minted only through a closure set in its static block; the class value is not exported (only its type), so no module can name the constructor. A spread is a plain object without the private members and is not assignable; Object.assign / defineProperty on the frozen instance throw. Planted reds: degrading the token to a plain public shape makes both @ts-expect-error directives unused (2 tsc errors); the previous branded literal fails the new runtime retarget test.
1 parent 39a9b6c commit bb0f894

2 files changed

Lines changed: 80 additions & 26 deletions

File tree

src/daemon/handlers/__tests__/session-runtime-admission.test.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,36 @@ test('throws when no facts inspection seam was supplied', async () => {
3535
);
3636
});
3737

38-
test('the admission proof cannot be written down: only admitRuntimePlan mints it', () => {
38+
test('the admission cannot be written down, constructed, or copied: only admitRuntimePlan mints it', async () => {
3939
const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true });
40-
// A planted red for the seam itself: delete the proof key from AdmittedRuntimePlan and this
41-
// directive becomes unused, which tsc reports. The key is a module-private symbol, so no
42-
// literal in any other module can satisfy the type — a route holds a token only by having
43-
// called admitRuntimePlan (or by a type assertion, which the cutover gate rejects in src/daemon/).
44-
// @ts-expect-error a literal without the module-private proof key is not an admission
45-
const forged: AdmittedRuntimePlan<typeof plan> = { admitted: true, device: IOS_SIMULATOR, plan };
46-
expect(forged.plan).toBe(plan);
40+
const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts });
41+
if (!admission.admitted) throw new Error('unreachable');
42+
// Planted reds for the seam itself: each directive below becomes unused — and tsc fails — if
43+
// the token loses its #private payload or its private constructor. A route holds a token only
44+
// by having called admitRuntimePlan (or by a type assertion, which the cutover gate rejects in
45+
// src/daemon/).
46+
// @ts-expect-error a literal is not an admission: it lacks the #private payload
47+
const literal: AdmittedRuntimePlan<typeof plan> = { admitted: true, device: IOS_SIMULATOR, plan };
48+
// @ts-expect-error a spread of a real admission is a plain object without the #private payload
49+
const retargeted: AdmittedRuntimePlan<typeof plan> = { ...admission, device: ANDROID_EMULATOR };
50+
// The class value is not exported at all, so `new` is not even nameable from here — there is
51+
// nothing to plant; the module's only runtime export that yields a token is admitRuntimePlan.
52+
expect([literal, retargeted].length).toBe(2);
53+
});
54+
55+
test('at runtime a token cannot be retargeted either: spreads carry nothing and assignment throws', async () => {
56+
const plan = resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true });
57+
const admission = await admitRuntimePlan({ device: IOS_SIMULATOR, plan, inspectFacts });
58+
if (!admission.admitted) throw new Error('unreachable');
59+
// The payload lives in #private fields behind getter-only accessors on the prototype: a spread
60+
// copies own enumerable properties, of which there are none.
61+
expect(Object.keys({ ...admission })).toEqual([]);
62+
expect(({ ...admission } as { device?: unknown }).device).toBeUndefined();
63+
// Getter-only + frozen: neither assignment nor redefinition can move the device.
64+
expect(() => Object.assign(admission, { device: ANDROID_EMULATOR })).toThrow(TypeError);
65+
expect(() => Object.defineProperty(admission, 'device', { value: ANDROID_EMULATOR })).toThrow(
66+
TypeError,
67+
);
68+
expect(admission.device).toBe(IOS_SIMULATOR);
69+
expect(admission.plan).toBe(plan);
4770
});

src/daemon/handlers/session-runtime-admission.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,61 @@ export type RuntimePlan = Readonly<{
4444
use: Readonly<{ required: readonly RuntimeOperationKey<PlatformRuntimeOperations>[] }>;
4545
}>;
4646

47-
// The admission proof is a module-private symbol: no other module can name its type, so the
48-
// only way to hold an AdmittedRuntimePlan is to have received it from admitRuntimePlan (or to
49-
// forge one with a type assertion, which the cutover gate's manufactured-proof column rejects
50-
// in src/daemon/). A facts-first binder that requires the token therefore enforces
51-
// "admit before bind" at the seam, without inspecting the route's syntax.
52-
const admissionProof: unique symbol = Symbol('agent-device.runtime-plan-admitted');
47+
// Minted only inside the class's static block below: the constructor is private, so no other
48+
// module (or a spread, or Object.assign) can produce or retarget one — see the class doc.
49+
let mintAdmission: <Plan extends RuntimePlan>(
50+
device: DeviceInfo,
51+
plan: Plan,
52+
) => AdmittedRuntimePlanToken<Plan>;
5353

5454
/**
5555
* Proof that every operation `plan.use` requires is available on `device`'s owner facts. The
5656
* token names the device the facts were read for, and a facts-first binder binds *that* device
5757
* from the token rather than taking one separately — so facts admitted for device A can never
5858
* bind device B.
59+
*
60+
* It is a nominal, non-copyable object rather than a branded literal: the payload lives in
61+
* `#private` fields behind getter-only accessors, so a spread (`{ ...admission, device: other }`)
62+
* yields a plain object that lacks the private members and is not assignable to this type, and
63+
* `Object.assign(admission, { device })` throws on the getter-only property. The constructor is
64+
* private and the class value is not exported — only its type is — so no other module can even
65+
* name the constructor at runtime; the only caller is `admitRuntimePlan`, through a closure
66+
* captured in the static block. What remains is a type assertion, which the cutover gate's
67+
* manufactured-proof column rejects in src/daemon/. A facts-first binder that requires the token
68+
* therefore enforces "admit before bind" — for this device, for this plan — at the seam.
5969
*/
60-
export type AdmittedRuntimePlan<Plan extends RuntimePlan> = Readonly<{
61-
admitted: true;
62-
device: DeviceInfo;
63-
plan: Plan;
64-
readonly [admissionProof]: true;
65-
}>;
70+
class AdmittedRuntimePlanToken<Plan extends RuntimePlan> {
71+
static {
72+
mintAdmission = (device, plan) => {
73+
const token = new AdmittedRuntimePlanToken(device, plan);
74+
Object.freeze(token);
75+
return token;
76+
};
77+
}
78+
79+
readonly #device: DeviceInfo;
80+
readonly #plan: Plan;
81+
82+
private constructor(device: DeviceInfo, plan: Plan) {
83+
this.#device = device;
84+
this.#plan = plan;
85+
}
86+
87+
get admitted(): true {
88+
return true;
89+
}
90+
91+
/** The device whose owner facts admitted the plan — the only bind target a binder may use. */
92+
get device(): DeviceInfo {
93+
return this.#device;
94+
}
95+
96+
get plan(): Plan {
97+
return this.#plan;
98+
}
99+
}
100+
101+
export type AdmittedRuntimePlan<Plan extends RuntimePlan> = AdmittedRuntimePlanToken<Plan>;
66102

67103
export type RefusedRuntimePlan<Plan extends RuntimePlan> = Readonly<{
68104
admitted: false;
@@ -87,12 +123,7 @@ export async function admitRuntimePlan<const Plan extends RuntimePlan>(
87123
const fact = facts.operations[operation];
88124
if (!fact.available) return { admitted: false, operation, fact };
89125
}
90-
return Object.freeze({
91-
admitted: true,
92-
device: params.device,
93-
plan: params.plan,
94-
[admissionProof]: true as const,
95-
});
126+
return mintAdmission(params.device, params.plan);
96127
}
97128

98129
export function requireRuntimeBinding(

0 commit comments

Comments
 (0)