Skip to content

Commit ded068f

Browse files
committed
fix(selectors): check the payload ceiling after the last downgrade drop
The writer now measures again after dropping label and throws if role and id still exceed the ceiling, instead of returning a payload its parser rejects. The role-and-id invariant test derives its bound from the field cap and the largest JSON escape expansion rather than a hand-built payload.
1 parent 2ad2989 commit ded068f

3 files changed

Lines changed: 19 additions & 10 deletions

File tree

‎packages/ad-script/docs/target-annotation.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ The writer then drops `rect`, `scrollRegion`, the remaining `ancestry`, and `lab
7171
It measures the payload again after each drop and stops when the payload fits.
7272
Replay refuses recorded `"unverifiable"` evidence before it reads these fields, so the drops do not weaken replay.
7373
A capped field escapes to at most six times its limit, so `role` and `id` alone always fit and the writer never drops them.
74+
If a field limit change breaks that bound, the writer throws instead of writing a payload that exceeds the limit.
7475
When the reduced tuple fits, the record-time self-check uses that tuple.
7576
A `verified` claim therefore applies to exactly the written evidence.
7677

‎packages/selectors/src/target-evidence.test.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ test('computeTargetEvidence: max-size labels across K=8 ancestors reduce ancestr
225225
// control character is one raw byte and six escaped ones (`\u0001`), so fields at their cap can
226226
// still overflow after the writer has dropped ancestry to its floor and dropped `rect`.
227227
const ESCAPE_HEAVY_FIELD = '\u0001'.repeat(TARGET_ANNOTATION_MAX_FIELD_BYTES);
228+
/** JSON bytes per raw byte for a one-byte control character, the largest expansion JSON escaping makes. */
229+
const MAX_JSON_ESCAPE_EXPANSION = JSON.stringify('\u0001').length - '""'.length;
228230

229231
test('computeTargetEvidence: escape-heavy fields degrade until the unverifiable payload fits and reparses', () => {
230232
const nodes = toSnapshotNodes([
@@ -330,20 +332,23 @@ test('computeTargetEvidence: an oversized payload drops rect, scrollRegion, ance
330332
// The downgrade never drops `role` or `id`, so it relies on the two fitting even fully escaped. A
331333
// larger field cap breaks this, and the writer would again emit a payload its parser rejects.
332334
test('computeTargetEvidence: a downgrade with every optional field dropped fits at the worst-case escape ratio', () => {
333-
const fullyDropped = {
334-
id: ESCAPE_HEAVY_FIELD,
335-
role: ESCAPE_HEAVY_FIELD,
335+
const skeleton = serializeTargetAnnotationV1({
336+
id: '',
337+
role: '',
336338
ancestry: [],
337339
sibling: Number.MAX_SAFE_INTEGER,
338340
viewportOrder: Number.MAX_SAFE_INTEGER,
339-
verification: 'unverifiable' as const,
340-
};
341+
verification: 'unverifiable',
342+
});
343+
const retainedFields = ['id', 'role'].length;
341344

342-
const json = serializeTargetAnnotationV1(fullyDropped);
345+
const worstCaseBytes =
346+
utf8ByteLength(skeleton) +
347+
retainedFields * MAX_JSON_ESCAPE_EXPANSION * TARGET_ANNOTATION_MAX_FIELD_BYTES;
343348

344349
assert.ok(
345-
utf8ByteLength(json) <= TARGET_ANNOTATION_MAX_PAYLOAD_BYTES,
346-
`payload is ${utf8ByteLength(json)} bytes`,
350+
worstCaseBytes <= TARGET_ANNOTATION_MAX_PAYLOAD_BYTES,
351+
`role and id at the field cap escape to a ${worstCaseBytes}-byte payload`,
347352
);
348353
});
349354

‎packages/selectors/src/target-evidence.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,13 @@ const OVERSIZED_CANDIDATE_DEGRADATIONS: readonly ((candidate: TargetAnnotationV1
168168
function finalizeOversizedCandidate(candidate: TargetAnnotationV1): TargetAnnotationV1 {
169169
candidate.verification = 'unverifiable';
170170
for (const degrade of OVERSIZED_CANDIDATE_DEGRADATIONS) {
171-
if (fitsPayloadCeiling(candidate)) break;
171+
if (fitsPayloadCeiling(candidate)) return candidate;
172172
degrade(candidate);
173173
}
174-
return candidate;
174+
if (fitsPayloadCeiling(candidate)) return candidate;
175+
throw new Error(
176+
'target annotation exceeds the payload ceiling with only role and id left; the field cap no longer fits the ceiling',
177+
);
175178
}
176179

177180
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)