Handlers get a response facade, and an ack must be a recorded generation

sdk-qa re-round on cb6dfac — two MUST-FIX, both reproduced on that tip first.

MF1 — the request confinement leaked through the response object. Removing req
from ctx was real, but handlers still received Node's res, whose res.req
back-references the request: an ordinary handler read the url via res.req.url
(reproduced: it returned "/api/qa-leak?secret=xyz"). The design note overstated
"cannot reach in any spelling" — false while res.req was reachable. Handlers now
receive a RESPONSE FACADE exposing only writeHead/setHeader/write/end/pipeFrom,
closing over res in a scope they cannot reach; every method returns the facade,
a primitive, or undefined — never res. No object in handler scope retains the
request. files/raw streams via res.pipeFrom instead of piping to the raw res.
Control plants the back-reference read (res.req.url, the socket parser, the
connection) into a real handler, boots, and asserts every path yields nothing.
The note is corrected to state the facade is the mechanism and to stop
overstating. The req-confinement guard and adapter count are unchanged (the
facade uses res, not req).

MF2 — monotonic+bounded is not membership. Bounding the ack by the surface's
own latest gen still admitted a foreign one: with A=1, B=2, A=3 recorded, A's
latest is 3 and 2 <= 3, so acking 2 (recorded for B) on A passed. Each surface
now tracks the set of generations recorded FOR IT; an ack must be a MEMBER of
that set and forward of the watermark, so a future gen (never recorded) and a
foreign gen (recorded for another surface) are both refused. Generations at or
below the watermark are pruned. The interleaved discriminator (A=1,B=2,A=3, ack
2 on A) is added to the bounded-ack control and refused; A's own gen 3 accepted.

367 -> 368 tests, 368/368 green.
This commit is contained in:
mvschwarz committed 2026-08-08 12:29:13 -07:00
1 parent cb6dfacdd2
commit 5d4846877a
4 files changed
+94 -23

No files matched your search

+36 -12
View File
@@ -836,7 +836,11 @@ let driveSeq = 0;
let driveReads = 0; // total polls across all surfaces (contract report)
const driveSlot = (surface) => {
let slot = driveSlots.get(surface);
if (!slot) { slot = { op: null, appliedGen: 0, reads: 0 }; driveSlots.set(surface, slot); }
// `recorded` is the set of generations actually recorded FOR THIS surface,
// above its applied watermark — an ack must be a MEMBER of it, not merely
// monotonic and below the latest gen. `gen` is global, so A=1, B=2, A=3 leaves
// A's recorded set {1,3}: acking 2 (B's gen) is a foreign gen and refused.
if (!slot) { slot = { op: null, appliedGen: 0, reads: 0, recorded: new Set() }; driveSlots.set(surface, slot); }
return slot;
};
// Per-surface marker: a surface watches its OWN slot's latest generation.
@@ -861,6 +865,7 @@ function writeDrive(surface, op) {
driveSeq += 1;
const slot = driveSlot(surface);
slot.op = { ...op, gen: driveSeq, at: new Date().toISOString() };
slot.recorded.add(driveSeq); // this generation IS recorded for this surface
// ACCEPTED IS NOT DELIVERED, AND THE CALLER FINDS OUT HERE. `listening` is
// false when THIS surface has never polled: the op is recorded and that surface
// will not act on it. Reported in the response the caller is already reading.
@@ -1127,16 +1132,19 @@ const API_ROUTES = [
const slot = driveSlot(surface);
slot.reads += 1;
driveReads += 1;
// AN ACK IS BOUNDED TO A GENERATION ACTUALLY RECORDED FOR THIS SURFACE, and
// monotonic within that bound (review.r2 MF2). Without the upper bound a
// caller that knows a surface id could POST `?applied=999999` and move the
// acknowledgement permanently ahead of every future op — externally
// reachable state mutation that made the record-to-apply gap false without
// any apply. A gen above the latest recorded op (a FUTURE gen, or a FOREIGN
// gen recorded for another surface, since `gen` is global) is ignored.
// AN ACK MUST BE A GENERATION ACTUALLY RECORDED FOR THIS SURFACE — MEMBERSHIP,
// not merely monotonic and below the latest gen (review.r2 MF2, re-round).
// `gen` is global, so bounding by the surface's own latest gen still admits
// a FOREIGN gen: A=1, B=2, A=3 leaves A's recorded set {1,3}, and 2 <= 3 —
// acking 2 on A once passed. Now the ack must be IN A's recorded set and
// forward of the watermark; a future gen (never recorded) and a foreign gen
// (recorded for another surface) are both refused. Once acked, generations
// at or below the watermark are pruned — monotonicity keeps them un-ackable.
const applied = Number(ctx.searchParams.get("applied"));
const recorded = slot.op ? slot.op.gen : 0;
if (Number.isInteger(applied) && applied > slot.appliedGen && applied <= recorded) slot.appliedGen = applied;
if (Number.isInteger(applied) && applied > slot.appliedGen && slot.recorded.has(applied)) {
slot.appliedGen = applied;
for (const g of slot.recorded) if (g <= slot.appliedGen) slot.recorded.delete(g);
}
const since = ctx.searchParams.get("since");
const marker = driveMarker(slot);
return sendJson(res, 200, { ok: true, surface, changed: since === null || since !== marker,
@@ -1266,7 +1274,7 @@ const API_ROUTES = [
}
res.writeHead(200, { "content-type": TYPES[path.extname(r).toLowerCase()] || "application/octet-stream",
"content-length": st.size, "cache-control": "no-store" });
return fs.createReadStream(r).pipe(res);
return res.pipeFrom(fs.createReadStream(r));
} },
{ verb: "/api/files/search", handler: (ctx, res) => {
const q = (ctx.searchParams.get("q") || "").trim();
@@ -1306,7 +1314,23 @@ const handleRequest = async (req, res) => {
readBody: () => readBody(req),
onClose: (cb) => req.on("close", cb),
};
await row.handler(ctx, res);
// A RESPONSE FACADE, not the raw `res` (MF1, sdk-qa re-round). Node's
// ServerResponse back-references the request as `res.req` (and via the
// socket), so handing handlers `res` let an ordinary handler read the
// url through `res.req.url` — the ctx confinement was real but the
// response object leaked around it. This facade exposes ONLY the
// response methods handlers use and closes over `res` in a scope they
// cannot reach; every method returns `resFacade`, a primitive, or
// undefined — NEVER `res` — so no object in handler scope retains the
// request. `sendJson(resFacade, …)` works (writeHead + end).
const resFacade = {
writeHead(...a) { res.writeHead(...a); return resFacade; },
setHeader(...a) { res.setHeader(...a); return resFacade; },
write(...a) { return res.write(...a); },
end(...a) { res.end(...a); },
pipeFrom(readable) { readable.pipe(res); },
};
await row.handler(ctx, resFacade);
} else sendJson(res, 404, m.notFound);
} catch (e) { return sendJson(res, 500, { ok: false, error: String(e.message || e) }); }
return;
+21 -9
View File
@@ -25,12 +25,18 @@ never holds a pathname at all.
- **The raw request is CONFINED to the routing adapter** (MF1, sdk-qa).
`handleRequest` references `req` only to route (`routeRequest(req.url, …)`) and
to build a handler `ctx`. Handlers receive
`{ method, headers, searchParams, verb, rest, readBody, onClose }` — **no url
and no `req`** — so response-capable handler code cannot reach the raw request
in ANY spelling (`req.url`, `req["url"]`, destructuring, aliasing).
**This is the mechanism: the boundary is that the url is not in scope, not that
a guard forbids one spelling of reading it.** An earlier fix counted the literal
`req.url` and a property-access spelling walked straight past it.
`{ method, headers, searchParams, verb, rest, readBody, onClose }` — no url and
no `req` — **and a RESPONSE FACADE, not the raw `res`.** The facade matters:
Node's `ServerResponse` back-references the request as `res.req`, so a handler
holding the real `res` could read `res.req.url` around the ctx — the ctx
confinement was real but the response object leaked. The facade exposes only
`writeHead`/`setHeader`/`write`/`end`/`pipeFrom`, closes over `res` in a scope
the handler cannot reach, and every method returns the facade, a primitive, or
`undefined` — never `res`. So **no object in handler scope retains the
request.** This is the mechanism: the boundary is that neither the url nor
anything reaching it is in handler scope. (Two earlier fixes were narrower — one
counted the literal `req.url` and a property-access spelling walked past it;
one removed `req` from ctx but left the request reachable through `res.req`.)
- Handlers receive `ctx`, never a pathname or a URL object. A handler needing
path data uses `rest`, which is already scoped to its own matched verb.
- `handleRequest` calls `routeRequest` and dispatches the indexed row. Neither
@@ -60,9 +66,11 @@ without a row" is a fact about the data flow, not a promise a guard checks.
is live while API requests are in flight — the exact material of the round-5
finding. The unwritability control in `test/reserved-verbs.test.mjs` will
fail if you do this.
- **Handlers take `ctx`, not `req` or `u`.** Widening a handler back to
`(request, req, res)` — or passing the URL object — hands it the raw request
and its url again, and the MF1 boundary is gone. The confinement guard fails.
- **Handlers take `ctx` and a response FACADE, not `req`, `u`, or the raw `res`.**
Widening a handler back to `(request, req, res)`, passing the URL object, or
handing it the raw `res` (whose `res.req` back-references the request) all
reopen the MF1 leak. The confinement guard and the response-back-reference
control fail.
- **`matchApi` and `routeRequest` live in `app/verbs.mjs`, not beside the table.** The grammar
module is the routing definition; a special case must be written into the
definition of routing itself, where the `routes=[]` property test and review
@@ -83,6 +91,10 @@ exist to catch a change that reintroduces it.
`req`, so the identifier count catches the property-access equivalents the old
`req.url` text-match missed (MF1). Its control plants literal, bracket,
destructured, and aliased raw-url reads and asserts each is caught.
- **response-back-reference control** (`test/reserved-verbs.test.mjs`): plants a
handler that tries to read the url through the response object (`res.req.url`,
the socket's parser, the connection) and asserts every path yields nothing —
the runtime proof that the facade retains no reference to the request.
- **Unwritability control** (`test/reserved-verbs.test.mjs`): plants sdk-qa's
round-4 literal arm and round-5 derived-prefix arm at the top of the API flow
and asserts each detonates as a `ReferenceError` naming the absent binding —
+13 -2
View File
@@ -396,11 +396,22 @@ test("SETTLE-ACK is BOUNDED: a future or foreign generation cannot be acked", as
const foreign = await s.readAt("A", { applied: "2" });
assert.equal(foreign.appliedGen, 0, "a generation recorded for another surface was acked on this one");
// The legitimate ack (== the recorded gen for A) is accepted and monotonic.
// The legitimate ack (a gen recorded for A) is accepted and monotonic.
const ok = await s.readAt("A", { applied: "1" });
assert.equal(ok.appliedGen, 1, "a valid ack bounded to the recorded generation was refused");
assert.equal(ok.appliedGen, 1, "a valid ack of a recorded generation was refused");
const back = await s.readAt("A", { applied: "0" });
assert.equal(back.appliedGen, 1, "appliedGen went backwards — monotonicity lost");
// THE INTERLEAVED DISCRIMINATOR (sdk-qa re-round): A=1, B=2, A=3. Gen 2 is
// below A's latest recorded gen (3) but was NEVER recorded for A — membership,
// not just an upper bound. Acking 2 on A must be refused; acking 3 (A's own)
// accepted.
await s.driveTo("A", { play: true }); // A now has gen 3 (B's gen 2 sits between)
const interleaved = await s.readAt("A", { applied: "2" });
assert.equal(interleaved.appliedGen, 1,
"gen 2 (recorded for B, below A's latest) was acked on A — monotonic+bounded is not membership");
const own = await s.readAt("A", { applied: "3" });
assert.equal(own.appliedGen, 3, "A's own recorded generation 3 was refused");
});
test("SETTLE-ACK: appliedGen exposes a real posted-but-not-yet-applied window", async (t) => {
+24
View File
@@ -569,6 +569,30 @@ test("MF1 CONTROL: every semantic-equivalent raw-url access at the adapter entry
}
});
test("MF1 CONFINEMENT: a handler cannot read the request URL through the response object", async () => {
// review.r2 MF1 re-round: the ctx confinement was real, but handlers still
// received Node's `res`, which back-references the request as `res.req` — an
// ordinary handler read the url through `res.req.url` (reproduced on the prior
// tip). The response the handler receives is now a FACADE that exposes only
// response methods and retains no reference to the request. This control plants
// the back-reference read into a real handler, boots, and asserts the url is
// unreachable through every path off the response object.
const src = read("app", "serve-studio.mjs");
const anchor = 'return sendJson(res, 200, { ok: true, hits: q.length < 2 ? [] : filesSearch(q) });';
assert.ok(src.includes(anchor), "the files/search handler moved — re-anchor this control before trusting it");
const planted = src.replace(anchor,
'return sendJson(res, 200, { ok: true, hits: [], ' +
'qaLeak: (res.req && res.req.url) || (res.socket && res.socket.parser && res.socket.parser.incoming && res.socket.parser.incoming.url) || (res.connection && res.connection.parser) || null });');
assert.notEqual(planted, src, "the leak plant did not apply");
const { statuses, bodies } = await bootMutated(planted, ["/api/files/search?secret=leak-me"]);
assert.equal(statuses["/api/files/search?secret=leak-me"], 200, "the planted handler did not answer");
const body = JSON.parse(bodies["/api/files/search?secret=leak-me"]);
assert.equal(body.qaLeak, null,
`a handler read the request URL through the response object (${JSON.stringify(body.qaLeak)}) — ` +
`the confinement leaks around ctx via the response's request back-reference`);
});
test("I6: /api/files/raw honours the stable error contract — 4xx JSON, and a directory is refused before any 200", async () => {
// review.r2 IMPORTANT: a bare empty 404 violated "all client errors are 4xx
// JSON { ok:false, error }", and a directory passed existsSync, got 200 headers