Skip to content

Commit ea374a2

Browse files
committed
refactor(capture-kit): pin baseline provenance at compile time
Keep the negative half of the baseline union under typecheck: a post-action reading cannot enter the pending record's baseline, a distrust decision exposes no pre-action payload, and a baseline is constructed with its comparison key. Restore the explicit empty-signature short circuit the base verdict carried, and pin it with a case. Tighten the comments the review flagged.
1 parent 54fe25e commit ea374a2

4 files changed

Lines changed: 59 additions & 5 deletions

File tree

‎packages/capture-kit/src/post-gesture-stability.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
44
import type { ObservationClock } from './observe-until.ts';
55
import {
66
runPostGestureStabilityLoop,
7+
type PostGestureBaseline,
78
type PostGesturePreActionBaseline,
9+
type PostGestureStabilityDecision,
810
type PostGestureStabilityHooks,
11+
type PostGestureStabilityPending,
912
} from './post-gesture-stability.ts';
1013

1114
vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => {
@@ -19,6 +22,40 @@ beforeEach(() => {
1922

2023
type Surface = Readonly<{ signature: readonly string[]; costMs: number; backend?: string }>;
2124

25+
// The negative half of these lines is type-level: each @ts-expect-error fails `pnpm typecheck`
26+
// the moment the baseline union would accept a post-action reading as pre-action evidence again
27+
// (#3868). The pre-action-only slots are the pending record's baseline and the accept-stale
28+
// payload; only the writer may fill them.
29+
test('the baseline union rejects a post-action reading in every pre-action slot', () => {
30+
const postAction: PostGestureBaseline<readonly string[]> = {
31+
provenance: 'post-action',
32+
signature: ['a'],
33+
comparisonKey: 'key',
34+
};
35+
// @ts-expect-error a rebased baseline is not pre-action evidence for the pending record
36+
const pendingBaseline: PostGesturePreActionBaseline<readonly string[]> = postAction;
37+
const pending: PostGestureStabilityPending<readonly string[]> = {
38+
action: 'scroll',
39+
positionals: ['down'],
40+
// @ts-expect-error a rebased baseline is not pre-action evidence for the pending record
41+
baseline: postAction,
42+
};
43+
const decision = undefined as unknown as PostGestureStabilityDecision<readonly string[]>;
44+
const consumeDistrust = () => {
45+
if (decision.verdict === 'distrust') {
46+
// @ts-expect-error a distrust decision exposes no pre-action baseline to consume
47+
void decision.preActionBaseline;
48+
}
49+
};
50+
// @ts-expect-error a baseline is constructed complete: the comparison key is not optional
51+
const keyless: PostGesturePreActionBaseline<readonly string[]> = {
52+
provenance: 'pre-action',
53+
signature: ['a'],
54+
};
55+
56+
void [pendingBaseline, pending, keyless, decision, consumeDistrust];
57+
});
58+
2259
function fakeClock(): ObservationClock & { advance(ms: number): void } {
2360
let nowMs = 0;
2461
return {

‎packages/capture-kit/src/post-gesture-stability.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export type BaselineSurfaceEvidence = 'changed' | 'unchanged' | 'ambiguous';
4545
/**
4646
* One surface reading that can stand as a comparison baseline. Signature and key travel together:
4747
* captures from different surfaces are not views of one screen (#1569, #2438). `comparisonKey` is
48-
* undefined for legacy observations that carry no key; absence is not proof of sameness.
48+
* undefined for legacy observations that carry no key. A quiet pair with two absent keys does not
49+
* rebase: absent keys never prove a surface change, and never prove sameness either.
4950
*/
5051
export type PostGestureSurfaceReading<S> = Readonly<{
5152
signature: S;
@@ -149,7 +150,11 @@ export function decidePostGestureStabilityVerdict<S extends readonly unknown[]>(
149150
classifyBaselineEvidence: (baseline: S, quiet: S) => BaselineSurfaceEvidence;
150151
}): PostGestureStabilityDecision<S> {
151152
const { baseline, quietSignature, elapsedMs, distrustCapMs } = params;
152-
if (baseline?.provenance !== 'pre-action') return { verdict: 'trust' };
153+
// An empty baseline is no baseline: never classify against it, whatever the classifier does
154+
// with empties. The writer normalizes `[]` away, so this guards a caller-constructed value.
155+
if (baseline?.provenance !== 'pre-action' || baseline.signature.length === 0) {
156+
return { verdict: 'trust' };
157+
}
153158
if (params.classifyBaselineEvidence(baseline.signature, quietSignature) !== 'unchanged') {
154159
return { verdict: 'trust' };
155160
}

‎packages/daemon/src/core/__tests__/post-gesture-stabilization-verdict.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
decidePostGestureStabilityVerdict as decideWithHooks,
1010
type PostGestureBaseline,
11+
type PostGesturePreActionBaseline,
1112
} from '@agent-device/capture-kit/post-gesture-stability';
1213
import {
1314
applicationRootNode,
@@ -27,7 +28,7 @@ const decidePostGestureStabilityVerdict = (params: {
2728

2829
function preActionBaseline(
2930
signature: InteractionSurfaceSignature,
30-
): PostGestureBaseline<InteractionSurfaceSignature> {
31+
): PostGesturePreActionBaseline<InteractionSurfaceSignature> {
3132
return { provenance: 'pre-action', signature, comparisonKey: 'runner|app' };
3233
}
3334

@@ -58,6 +59,17 @@ test('decidePostGestureStabilityVerdict trusts immediately when no baseline was
5859
}),
5960
{ verdict: 'trust' },
6061
);
62+
// The writer never stores an empty signature, but a caller-constructed one is still no
63+
// baseline: it must trust without ever consulting the classifier (#1542 base coverage).
64+
assert.deepEqual(
65+
decidePostGestureStabilityVerdict({
66+
baseline: { provenance: 'pre-action', signature: [], comparisonKey: 'runner|app' },
67+
quietSignature: signature,
68+
elapsedMs: 0,
69+
distrustCapMs: 3_500,
70+
}),
71+
{ verdict: 'trust' },
72+
);
6173
});
6274

6375
test('decidePostGestureStabilityVerdict trusts a post-action baseline match at any elapsed time', () => {

‎packages/daemon/src/core/deferred-interaction-outcome.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ function markSurfaceStabilization(
132132
// never stored: "no usable baseline" has exactly one
133133
// representation (absent), so no consumer has to tell `undefined` from `[]`
134134
// — and the loop cannot rebase a baseline that was never really there.
135-
// Constructing the baseline here is also the whole distrust authorization: the
136-
// loop is never separately told a platform boolean that could disagree.
135+
// Constructing the baseline here is the whole distrust authorization. The loop is
136+
// not given a second platform flag that could disagree with it.
137137
const baselineSignature = requiresPostGestureBaselineDistrust(session.device)
138138
? buildInteractionSurfaceSignature(session.snapshot?.nodes ?? [])
139139
: undefined;

0 commit comments

Comments
 (0)