Skip to content

feat(capture,store): join input events into T1 cards so slots record what the user did - #41

Open
zxch3n wants to merge 21 commits into
mainfrom
ax-walk-menu-skip-timebox
Open

feat(capture,store): join input events into T1 cards so slots record what the user did#41
zxch3n wants to merge 21 commits into
mainfrom
ax-walk-menu-skip-timebox

Conversation

@zxch3n

@zxch3n zxch3n commented Aug 17, 2026

Copy link
Copy Markdown
Member

What this is

T1 cards misread IM apps: on the 2026-08-17 15:20 slot of the real vault, 67% of the prompt budget went to a Feishu sidebar the user never touched, and the resulting T2 card read "Feishu multi-group scan" while the actual 1:1 conversation went unmentioned. Between 15:20 and 16:20 the user was in essentially one conversation; five of six cards described scanning many groups.

The root cause was not the scoring — it was that T1 had only one fact stream and was inferring what the user did from what was on screen. Every fix attempted against that single stream failed: geometry heuristics (break per app), placeholder parsing (overfit to Feishu), churn (measures "something happened", not "the user did it" — in the group case it pointed at the sidebar while the engaged pane changed by zero characters), and handing the raw tree to the model (works on Haiku, fails on a 35B local model, inverts on a 4B — and T2 runs the user's configured model).

So this adds the second fact stream and joins them.

The evidence that picked the design

Three models × two real scenarios (Feishu 1:1 and a 3-person group), asked to identify the conversation the user was actually in:

representation size Haiku qwen3.6:35b qwen3.5:4b
current T1 (flat tree-order lines + IDF) ~4 KB ✗✗ ✗✗ ✗✗
pruned tree (structure + coordinates kept) 21–28 KB ✓✓ ✗✓ ✗—
generic region partition, no agency claim ~4.7 KB ✗✓ ✓✓ ✗✗
same + one asserted agency fact ~4.8 KB ✓✓ ✓✓ ✓✗

The last two rows differ by three lines of text. The asserted fact — "input landed in region 1; no input landed elsewhere" — is the only thing that flips results across model tiers, and its only honest source is input events. Local cost measured at ~5s/slot for the 4.8 KB shape on qwen3.6:35b-mlx, so the ceiling here is correctness, not latency.

Design and every number: docs/input-events-and-t1-acts-plan.md. Code map: context/acts-join.md.

What landed (18 commits, phases 0–4)

  • Phase 0 — the AX walk skipped nothing and had no time bound. Menus were 80–90% of walked nodes in native apps (Ghostty 205/257, Zed 245/276) with no consumer; each attribute read is synchronous IPC with a 6s default timeout, so one wedged app could stall ticks unboundedly. Now: AXMenuBar stubbed, 100ms per call, 500ms whole-walk budget.
  • Phase 1 — listen-only CGEventTap in the shim on its own thread. Typing bursts as counts (key codes classify command keys and never leave the callback), command keys incl. Return/Tab/Esc, clicks/scrolls resolved at event time to element identity with coordinates dropped in the resolving stack frame. Fails closed for excluded apps before the daemon's list arrives; fails open with a warning if the tap cannot be created; cross-checks CGEventSource idle time because taps die silently on code-signature changes — absence of events must never read as "the user did nothing".
  • Phase 2input_events table (schema 22), 48h retention, delete_history cascade. kind/target_json stored uninterpreted so a newer shim round-trips.
  • Phase 3 — the join. AX geometry parsed (the shim always wrote frame; Rust never read it), landing points hit-tested per frame, engaged scope = LCA expanded to ≥10% window area (the system's one knob). Runs carry acts; text splits into engaged vs peripheral; cards gain not_engaged and no_input_ratio.
  • Phase 4 — R3 edge snapshots: on a confirmed scope switch, after the user's hand stops (500ms settle, ≥5s apart, ≤6/min), one AX-only walk of the trigger's window. Never a screenshot — an event-driven frame would outlive the 48h events behind it and leak when interaction happened. Schema 23, retention aligned with events, fourth cascade layer.
  • Independent fixestheme_key was picking up Feishu's avatar native-resource://… URL and en-US.html (a path inside Lark.app, which shorten_place laundered into a plausible document name) was becoming the run title and a top_documents entry. One gate now, applied to raw and shortened values at all four call sites. The slot anchor moved off the first frame — measured, that frame carries the one-time sidebar dump.

Verified

  • afterray-store 210/210 (154 → 210; no pre-existing test rewritten), afterrayd 128 pass, afterray-platform-macos 18, protocol 39, codec 11, cli 2, infer 1. Swift: shim package 24, root 336.
  • Two known failures, both reproduced on origin/main in a clean worktree: afterrayd::tests::packer_encodes_closed_gop_and_serves_poster (GOP compression-ratio assertion) and 4 afterray-models::remote::stream tests (sandbox blocks localhost HTTP).
  • Fail-open is pinned byte-for-byte: the fixture was generated as its own commit before the pipeline changed, so it captures pre-acts behavior rather than whatever the new code produces. A slot with zero input events yields an identical card and prompt — line selection, ordering, line_frames, total_chars, more_chars, budgets all exact.
  • New concurrency/IO tests: 10/10 consecutive green (make test-repeat N=10).
  • Clippy adds zero findings mentioning new symbols; the ~114 workspace warnings are pre-existing nightly-pedantic noise (this toolchain is nightly 1.94, the repo targets 1.85), verified identical against a stashed base.

Not verified — please read before merging

  • No runtime path has been exercised. Nothing here ran against a live shim or a signed dev instance: whether the tap actually creates under the app's TCC identity, whether events reach the vault end-to-end, whether Electron's live element resolution is as good as the stored-tree hit-test measured at depth 21–39, and the whole R3 chain from trigger to artifact. All of it needs one make dev run — look for input tap started and capture input events batch=N in daemon stderr.
  • Open PoC that gates a product claim: does a listen-only tap put AfterRay in System Settings → Privacy → Input Monitoring? Documented as unresolved in §7.3; it decides whether we can say "verifiably does not monitor input".
  • Schema migration is one-way. First daemon start migrates the vault 21 → 23 (additive, no data loss), but a main build afterwards may refuse to open it. Use AFTERRAY_DATA_DIR for an isolated trial.
  • Corpus-level claims are unmeasured — thread hit rate, focus precision (33% baseline), and the 0.10 knob across apps are phase 5, which cannot start until the new shim has accumulated events.
  • Recorded deviations from the approved plan, with reasons, in the plan doc's 实现偏差 sections: the timeline is still cut by target_key (hysteresis splitting is implemented and tested, acts attach by overlap; re-cutting would move moment_id anchors, gaps and revisits), facts.apps[] acts summary / revisits / theme_key region derivation and the idle_ratio rename are deferred, and R3 skips all known browsers because the private-browsing verdict needs an async probe a 1s tick cannot afford.

Privacy

CAP-005 is enforced at the source, and the amendments are recorded in docs/slot-summaries-and-ax-pipeline.md §7.1/§7.2b rather than applied silently: Return/Tab/Esc are reclassified as command keys (no character content; "submit/execute" semantics), typing exists only as burst counts, pointer coordinates are never serialized — only the element identity they resolve to. AXSecureTextField values stay nulled. Forgetting a window now takes four layers with it: frames, cards, acts, and edge snapshots.

Two implementation choices were made stricter than the contract because a test caught the gap: partitioning is gated on the event stream itself rather than on caller convention, and an unavailable signal suppresses the engaged/peripheral text split too — splitting text into "operated" and "merely visible" is itself an agency claim.

Confidence

High on the deterministic layers (pure functions, pinned fail-open, 210 store tests). Medium on the end-to-end system, entirely because no runtime path has run. The measurement that motivated all of this is real and reproducible from the vault; the measurement that would confirm the fix is phase 5 and still owed.

🤖 Generated with Claude Code

zxch3n and others added 18 commits August 17, 2026 21:22
The AX walk was spending most of its nodes on the menu bar — measured
205/257 in Ghostty, 245/276 in Zed, ~170/1115 in Feishu — encrypted and
stored every 10s tick with no consumer: digests collect text roles only
and the store's text extraction asserts menus are chrome. Stub the
AXMenuBar subtree instead of descending.

The walk also had a node cap but no time bound, while every attribute
read is synchronous IPC into the target app (system default timeout 6s
per call): one wedged app could stall ticks unboundedly and lag the app
the user is working in. Bound both sides: process-global 100ms
messaging timeout at startup plus a 500ms whole-walk deadline that sets
`truncated`, same as the node cap.

Verified: build green; startup + JSON event protocol smoke-tested
(TCC-denied context, so real-walk behavior needs a signed dev run).
Groundwork for the input-events/T1-acts program (phase 0).

Model: claude-fable-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New docs/input-events-and-t1-acts-plan.md capturing the 2026-08-17
investigation and decisions: two-fact-streams principle (screen state
vs input events, joined by time and tree position; T1 never infers),
the 3-model x 2-scenario evidence matrix, R1/R2/R3 capture cadence,
CAP-005 amendments (Return/Tab/Esc as command keys, burst granularity,
element-identity persistence for pointer events), T1 reorganization
around acts, and the explicit not-doing list with experimental reasons.

Amends slot-summaries-and-ax-pipeline.md §7 tables in place and marks
the superseded items; indexes the plan in docs/AGENTS.md.

Model: claude-fable-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New InputEventMonitor in the capture shim: a listen-only CGEventTap on
its own thread whose callback only classifies and enqueues; all AX work
happens on a worker queue bounded by the process-global 100ms messaging
timeout. Emits coalesced `input_events` batches on stdout:

- typing bursts {start, end, count, ended_with} — key codes are read
  solely to classify command keys and never leave the callback
- command keys: ⌘-combos plus Return/Tab/Esc (2026-08-17 decision:
  they carry no character content; "submit/execute" semantics)
- clicks and scroll bursts resolved at event time to element identity
  (role/label/frame/ancestor chain); coordinates die with the
  resolving stack frame — never serialized
- producer-side coalescing (2s burst gap, 1s scroll gap, 40 records
  per flush window, overflow counted in `dropped`)
- exclusion: AfterRay itself and daemon-excluded apps are never
  recorded; fails closed before the exclusion list arrives (same
  posture as the audio hold), fails open with a warning event when
  the tap cannot be created
- liveness: taps die silently on code-signature changes, so the
  worker cross-checks CGEventSource idle time and emits
  `input_tap_stalled` + re-enables — absence of events must never
  read as "the user did nothing"

Rust side: CaptureEvent::InputEvents + record structs in
afterray-platform-macos (parse test included); the daemon logs and
drops batches for now — persistence (events table, 48h retention,
seal-time acts materialization) is phase 2 of
docs/input-events-and-t1-acts-plan.md.

Verified: shim builds; afterray-platform-macos 17/17 tests green;
afterrayd 127 tests green with only the pre-existing GOP
compression-ratio failure (fails identically on the base commit);
clippy adds zero new findings (5 pre-existing pedantic errors in
untouched files under nightly clippy, identical before/after).
Runtime behavior needs a signed dev run (TCC) — not yet exercised.

Model: claude-fable-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ise fixes

Concrete specs so implementation can proceed without the design
conversation: input_events DDL + Vault API + retention/cascade
invariants (phase 2), the acts join/hysteresis/materialization
contract with its fail-open invariant (phase 3), and the exact
targets for the independent T1 noise fixes. Materialization moves
from phase 2 to phase 3 — the acts shape is defined there.

Model: claude-fable-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bnails

Two independent T1 noise fixes measured against the production vault
(2026-08-17 15:20 slot), both confined to slot.rs.

Place noise: `target_key` accepted url/document/window_title unfiltered,
so Feishu's `native-resource://sdk/avatar?...` became the slot's
`theme_key`, and its bundled
`file:///Applications/Lark.app/.../en-US.html` became the run title and a
`facts.top_documents` entry — `shorten_place` reduces that path to a
plausible-looking `en-US.html`, which passed the label filters. Every
place candidate now runs through one gate, `is_place_noise`
(`is_chrome_noise` + `is_opaque_id` + the new `is_app_bundle_resource`),
applied to the raw value *and* its shortened form, at all four call
sites: `target_key`, `place_label`, and the `top_documents`/`top_urls`
aggregation. A rejected candidate falls through to the next; with every
candidate rejected the key degrades to the app-only form.

Anchor frame: the day summary anchored on `moment_ids.first()`. On real
data a slot's opening frame is the worst thumbnail available — a window
that just gained focus dumps its whole sidebar into that one frame (1399
characters of navigation). `SlotCard` now carries `anchor_moment_id`,
computed by the pure `anchor_frame_id` as the middle frame of the longest
run (ties to the earliest run); the first moment only stands in for a
card built without one. It lives on `SlotCard`, not `RunRow`, because a
`RunRow` field pushes `TimelineEntry` past clippy's `large_enum_variant`
threshold — and the piece's own row list gives the exact middle rather
than an estimate.

Verified: `cargo test -p afterray-store` green, 154 passed / 0 failed / 1
ignored, up from 145 at HEAD (three new tests here; the rest arrived with
concurrent work in the same tree). New tests replay both real-world
failures verbatim, the all-noise degradation, and a 3-run slot whose
longest run sits in the middle. `cargo clippy -p afterray-store
--all-targets -- -D warnings` still errors only on lints that a pristine
HEAD copy reproduces identically; slot.rs keeps exactly its two
pre-existing findings and gains none. `cargo check --workspace` clean.
Updated `assemble_keeps_t1_only_slots_and_overlays_t2_titles`: its
anchor assertion asserted only `is_some()` under the name "opening
frame", so it now names and pins the new rule (middle frame of the
longest run, `b` rather than `a`).

Not verified: no run against a real vault or the app UI — the thumbnails
this changes were judged from the recorded slot, not re-rendered.

Model: claude-opus-5
Harness: lody
Schema 22: new input_events table (at_ms/end_ms/kind/count/ended_with/
command/bundle_identifier/target_json) plus slot_summaries.acts_json,
added now so phase 3 needs no second migration. kind and target_json
are stored uninterpreted — the T1 join owns their meaning, and a newer
shim's kinds must round-trip. Never holds typed characters.

Vault API: insert_input_events (one transaction per batch, writer
connection), input_events_between (reader pool, overlap semantics,
ordered), prune_input_events (INPUT_EVENT_RETENTION_MS = 48h).
delete_history now cascades input_events — one privacy invariant,
three layers: forgetting a window takes the cards and the acts with
the frames. Not exposed via SharedReadOnlyVault.

Daemon: the InputEvents arm goes from log-only to run_store batch
insert (target serialized verbatim — InputTargetRef gains Serialize so
the store needs no second schema); prune runs beside enforce_retention.

Verified: afterray-store 154/154; afterrayd 127 green plus only the
pre-existing GOP compression-ratio failure; platform-macos 17/17;
concurrency test 10/10 consecutive green via make test-repeat; zero
clippy findings touching new code (the 114 workspace warnings are
pre-existing nightly-pedantic noise, none mention new symbols).

Implementation by an Opus subagent against the phase-2 contract in
docs/input-events-and-t1-acts-plan.md; verification re-run and docs
completed by the orchestrating session.

Model: claude-opus-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shim has always written a `frame` on every accessibility node (1106 of
1115 on a measured frame) and nothing ever read it. The input-event join
needs it: to say which region of a window a click landed in, the tree has
to carry geometry.

`memory.rs` gains a flattened, parent-linked arena — every question the
join asks is an upward walk, which the nested shape makes a search — and
`accessibility_text_lines` now delegates to the same traversal. That is
the load-bearing part: the `AX_TEXT_MIN_CHARS` text-source decision counts
the whole line vector and the join partitions that same vector, so the two
must never be able to disagree about what a line is.

`acts.rs` holds the geometry, pure: deepest node containing a landing
point, LCA of several, expansion to the smallest ancestor covering
ENGAGED_MIN_WINDOW_AREA_RATIO of its window — the single knob in the whole
join. It fails open everywhere it cannot answer honestly: nothing landed,
no window, an unmeasurable window frame all yield no scope, because an
invented scope reads downstream as "the user was here".

Model: claude-opus-5
Harness: lody
…itting

The acts block is what the user *did* over a stretch: keys, submits,
clicked labels, scrolls, and whether the input stream could be observed at
all. Fixed shape, every field always serialised — a reader has to be able
to tell "zero keys" from "keys unknown", and a missing field cannot.

Two decisions worth the space:

`ended_with` is not a submit. The shim emits both a burst carrying the key
that closed it and a separate `command` row for that same key; counting
both would double every Return in the slot, so only the command row
counts. Pinned by a test.

Run splitting is hysteretic. A new scope becomes a boundary only once
sustained — two events or fifteen seconds — because triage (glancing at
four conversations, answering one) otherwise shatters into four runs of one
click each, which is how a real slot came out as "multi-group scan" with
the actual 1:1 unmentioned. An un-promoted excursion folds back into the
run it interrupted, where its clicked labels survive as the record of the
glancing. An unresolved scope never forces a boundary: not knowing where
an event landed is not evidence that it landed somewhere new.

`unavailable` is not idle. A signal gap runs until input is observed again
and no further, and no engaged assertion may be made inside it.

Model: claude-opus-5
Harness: lody
Captured from the pipeline as it stands now, so the fail-open invariant is
anchored on behaviour that predates the change rather than on whatever the
change produces. A slot with no input events has nothing to partition by
and must come out byte-identical.

Only the clock-derived fields are normalised — day and HH:MM are the card's
one locale-dependent surface, and the fixture normalises both sides the
same way. Everything the partition could plausibly break (line selection,
ordering, line_frames, total_chars, more_chars, budgets) is pinned exactly.

Model: claude-opus-5
Harness: lody
A T1 card now says what was done, not only what was on screen. Each run
carries an `acts` block joined from the input stream, the text it introduced
is split into the engaged region and the merely visible, and the card names
the regions that received no input all slot.

This is the fix for a measured failure: on a real Feishu slot, 67% of the
prompt budget went to a conversation list the user never touched, and the
card came out as "multi-group scan" with the actual 1:1 unmentioned. The
engaged region now takes the whole existing budget through infoscore, so IDF
de-chromes within the bucket that matters instead of ranking a sidebar
against a conversation; peripheral text folds to 200 characters and a line
count, because "40 lines, not shown" is what a model needs from it.

Three things are load-bearing and each is pinned:

The text-source gate still counts every line. `AX_TEXT_MIN_CHARS` decides
AX-vs-OCR on the unfiltered vector, so a frame whose engaged pane is small
keeps its exact accessibility text instead of being silently demoted to
whole-screen OCR — which is the frame the join works best on.

Fail-open is structural, not conventional. Partitioning is gated on the
event stream itself, not on callers leaving `ax_join` unset, and the pinned
fixture from 19ae9f8 still passes byte-for-byte.

`unavailable` suppresses every engaged claim — including the partition
itself. Splitting text into operated and visible is an assertion about
agency, so a frame from a stretch the tap could not observe makes none, and
no region is called untouched there.

A run with an `ok` signal and zero acts is a fact, not a gap: "22 minutes
here, no keys" can only be stated by a stream that was running, and the
model is told the difference.

Model: claude-opus-5
Harness: lody
…xpire

Two holes closed at the ends of the acts pipeline.

The daemon was logging `input_tap_stalled` / `input_tap_unavailable` and
throwing them away. A dead tap has to be recorded *in* the event stream,
because T1 reads an absence of events as "the user did nothing here" — the
one inference this pipeline exists to prevent. The marker rides the same
table as a `signal_gap` row; the vault stores `kind` uninterpreted, so this
needed no schema change and the gap arrives in its place in time.

Events are deleted after 48 hours and T1 is computed lazily, so acts that
are not frozen simply vanish from history: two days on, a card would keep
the half that says what was on screen and silently lose the half that says
what the user did. The sweeper now freezes sealed slots into
`slot_summaries.acts_json`, and `slot_card` reads the frozen copy once the
events are gone.

The freeze runs before the T2 gate and independently of it. It is a short
read and one small write with no model in it, and the deadline it races is
physical — the events expire whether or not the machine was ever on AC
power with a charged battery. Gating it behind T2's conditions would lose
acts on exactly the laptops that stay unplugged.

What the frozen copy deliberately does not restore is the engaged/peripheral
partition: it was computed by hit-testing rects that no longer exist, so the
text goes back to whole rather than being partitioned against a guess.
Pinned, along with the roundtrip through expiry, the idempotence the
five-minute sweeper needs, and the delete_history cascade.

Model: claude-opus-5
Harness: lody
The store's AGENTS.md was already 1.5k over its ~4000-char budget before
this phase, so the join's detail goes where the repo's own rule sends it —
`context/acts-join.md` — and the index lines stay pointers. The article
carries the four invariants that may not be weakened (fail-open
byte-for-byte, the text-source gate, `unavailable` suppressing every engaged
claim, T1 purity), the measurements behind the 0.10 knob and the hysteresis,
and the reason `ended_with` is not counted as a submit.

The plan doc records what phase 3 did *not* do, which matters more than what
it did: timeline rows are still cut by `target_key` rather than re-cut by
engaged scope, only the v2 system prompt changed, and the facts.apps acts
summary and revisits/theme_key rework are untouched. Also the two places the
implementation came out stricter than the contract, both because a test
forced it.

Filed the remaining AGENTS.md overflow in CONTEXT-GAPS with a concrete next
extraction rather than leaving it as a silent violation.

Model: claude-opus-5
Harness: lody
…hots

Trigger/debounce/token-bucket parameters, the two v1 simplifications
(window-scoped walk instead of engaged-subtree; no shim-side power
degrade yet, bucket bounds the cost), the accessibility_edge artifact
kind with its no-moment/no-screenshot/no-OCR boundary, schema 23
edge_snapshots with 48h event-aligned retention and the fourth layer
of the delete_history cascade, and how edge trees join T1 as extra
partition frames only.

Model: claude-fable-5
Harness: lody
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 10s heartbeat misses content a person only looked at between two
ticks — stepping into a conversation for eight seconds and leaving.
R3 fills exactly that hole: a candidate (frontmost bundle change, or a
click), a 500ms settle window that any further input re-arms, and a
token bucket of >=5s spacing and <=6 walks per rolling minute.

The pacing rules are a pure state machine in `AfterRayCapturePolicy`
(`EdgeSnapshotPacing`) because both failure modes — walking the tree
while the user is still typing, and walking it thirty times a minute —
are invisible to any test that needs a live `CGEventTap`.

Never a screenshot: an event-driven frame would outlive the events that
triggered it and keep exposing interaction instants after the record of
the interaction was erased.

Two v1 simplifications, per the plan: the walk root is the whole
AXWindow the trigger landed in (focused window fallback) rather than the
engaged subtree, and known browsers are skipped entirely, because the
heartbeat's private-browsing gate needs an async automation probe a 1s
worker tick cannot afford.

Model: claude-opus-5
Harness: lody
SCHEMA_VERSION 22 -> 23. An R3 edge snapshot is an accessibility tree
with no moment: no screenshot, no thumbnail, no OCR. Its own table rather
than a column on `moments`, because hanging it off a frame would drag it
through every retention and export path that treats a moment as a picture
of the screen.

Retention is the events' own 48h, at the events' own call site: a tree
triggered by an event and outliving it would keep saying "the user was
here at 03:14" after the record of the input was erased. Pruning deletes
the encrypted files, not just the rows.

`delete_history` gains a fourth layer — frames, cards, acts, and now R3
trees. Nothing else could have reached them: they belong to no moment.

Artifacts have no purpose column, so the purpose rides the content type
(`purpose=edge-ax`). It is a constant, never a string copied off the
capture event: the encryption AAD binds the content type, so an artifact
stored under one spelling and read back under another is undecryptable.

Model: claude-opus-5
Harness: lody
`ArtifactKind::AccessibilityEdge` on the Rust side of the shim protocol,
and an import branch beside the accessibility one. It stores the tree as
an encrypted `edge-ax` artifact plus one `edge_snapshots` row: no moment,
no thumbnail, no OCR job — an edge snapshot is not a frame of the screen.

The exclusion check is the accessibility branch's, one notch stricter:
`edge_snapshot_identity` refuses a snapshot that does not name its app,
because the exclusion list is keyed by bundle identifier and an unnamed
app cannot be judged. The heartbeat branch has a screenshot already on
disk and must decide what to delete; this one loses nothing by dropping —
the next trigger is one interaction away. That decision is a pure
function so the fail-closed posture is testable without an AppState.

Edge retention runs at the events' own call site, from the same clock.

Model: claude-opus-5
Harness: lody
An edge tree is text and only text: it adds the lines no heartbeat frame
ever carried to the run it fell inside, partitioned into engaged and
peripheral by its own join exactly as a frame's lines are. It contributes
no moment_id, no anchor, no OCR evidence and no `facts` count — those all
answer "which frames does this card stand on", and an edge tree is not
one. Pinned by a test that diffs every one of them against the same card
built without edges.

Two deliberate limits. The join does not write resolved scopes back onto
the events from an edge tree: run splitting segments on those scopes, and
R3's job is to widen the text a run shows, not to re-cut the runs. And a
tree landing in a capture gap belongs to no run and is dropped, rather
than being attached to the nearest one — that would claim a window was on
screen during a stretch nothing was captured in.

Edge trees are gated on the event stream like the partition itself, so
the fail-open pin extends to them: a slot with edge trees and no events
produces the byte-for-byte pre-acts card. It cannot hold them by
construction — only input triggers a walk — and now a card cannot depend
on that staying true.

Model: claude-opus-5
Harness: lody
…-offs

Phase 4 marked done with six recorded deviations: known browsers are
skipped entirely (the heartbeat path's private-browsing verdict needs an
async probe a 1s tick cannot afford — skip rather than half-judge),
import rejects snapshots without a bundle identifier (the exclusion list
is keyed by it, and a dropped edge snapshot costs one interaction),
`edge-ax` rides the content type because artifacts have no purpose
column (AAD binds content type, so it must be a constant), edge frames
neither re-cut runs nor feed `not_engaged` nor pass the AX-vs-OCR
threshold, and trees landing in a capture gap are discarded rather than
attached to the nearest run.

Covers apps/AfterRayCaptureShim/AGENTS.md (R3 invariant + the new
XCTest suite), the platform/store/daemon AGENTS.md anchors, the
capture-pipeline stage notes, and the acts-join article's edge-frame
paragraph.

Model: claude-opus-5
Harness: lody
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 17, 2026

Copy link
Copy Markdown

Deploying afterray with  Cloudflare Pages  Cloudflare Pages

Latest commit: 62bf5c4
Status: ✅  Deploy successful!
Preview URL: https://64916c53.afterray.pages.dev
Branch Preview URL: https://ax-walk-menu-skip-timebox.afterray.pages.dev

View logs

zxch3n added 3 commits August 18, 2026 01:05
main's #42 made summary slot length a user setting and took schema 22
for `summary_slot_geometry`, which collided with this branch's own 22.
Git merged the two `migrate_schema_22` bodies into one file without a
conflict marker — a duplicate symbol that only the compiler catches, and
the more dangerous half of this merge. main's 22 is already released, so
it keeps the number: `input_events` becomes migration 23 and
`edge_snapshots` 24, with SCHEMA_VERSION 24.

The two migration tests simulated a pre-migration vault by stamping the
version below their own step; both still passed after the renumber
because the migrations are idempotent, but their names and stamps then
described the wrong step. Renamed and re-stamped — in this repo a test
name is the specification.

Both sides' tests kept where they collided at the top of afterrayd's
test module (edge-snapshot identity, and the pin that the settings
picker only offers lengths the vault accepts). The store AGENTS.md
anchors were rewritten to carry both stories — geometry history and
`slot_bounds_in` from main, the two fact streams and `acts.rs` from
here — with the long entries compressed toward context/acts-join.md,
and capture-pipeline.md's schema line refreshed (it had been stale at
18 on both sides).

Verified on the merged tree: afterray-store 217/217 (both sides' new
tests present, including the byte-for-byte zero-event fail-open pin),
afterrayd 129 pass with only the pre-existing GOP compression-ratio
failure, afterray-protocol 41, Swift root 340 and shim 24, workspace
`cargo check --all-targets` clean, and no clippy warning naming any
merged symbol.

Model: claude-opus-5[1m]
Harness: lody
…time

Three defects from review, and one reported defect that is not one.

**Typing was attributed to whatever focus said.** System focus is only as
precise as the app chooses to be, and the apps this pipeline exists for
are the imprecise ones: measured, Feishu reports `AXWebArea` for its
whole web view and Zed reports `AXWindow`. A landing point that coarse
drags the run's engaged scope up to the window through the LCA, which
re-creates the sidebar-noise bug this branch removes — and does it
exactly when the user is typing, the strongest evidence of engagement
there is. Bursts and command keys now fall back to the last click, which
resolved to a real element (measured depth 21–39). The rule is a role
decision, never an application one, and lives in the pure policy target
as `TypingTarget` with five tests.

**The 48h expiry rode the screen-import path**, so it only ran while
recording. Stop capture and the raw click targets and R3 trees stayed on
disk indefinitely, contradicting the contract. Expiry is a promise about
time: it now runs inside `enforce_retention` — above its size-sweep early
return, which fires whenever the vault is under its limit — so a mere
`Vault::open` expires them, and on the sweeper's ungated tick beside
`freeze_slot_acts`, which already exists because the same deadline is
physical. A test opens a vault with nothing recorded and asserts the
expired row is gone.

**A failed event batch vanished silently.** A stretch with no rows reads
as "the user did nothing", the one thing this pipeline may never say by
accident, so a failed insert now writes a `signal_gap` marker spanning
the batch. Extracted `record_signal_gap` so the dead-tap path and this
one cannot drift.

Also: the shim only stopped its monitor on the explicit `stop` command,
so a daemon crash closing the pipe lost the events buffered since the
last tick; `stop()` is idempotent and now runs on both exits.

**Not changed: the reported pointer-coordinate flip.** The claim was that
`CGEvent.location` is bottom-left origin and must be flipped for
`AXUIElementCopyElementAtPosition`. Measured instead of assumed: a
null-source `CGEvent` reports the cursor at y=562.6 while
`NSEvent.mouseLocation` — documented bottom-left — reports y=554.4 on a
1117pt screen, summing to exactly the screen height. The two spaces are
mirrors, so `CGEvent.location` is already top-left, like AX. Flipping it
would have introduced the very bug the review was trying to prevent.

Verified: afterray-store 218/218, shim XCTest 29/29, afterrayd 128 pass.
Two afterrayd failures are pre-existing and reproduce with these changes
stashed: the GOP compression-ratio assertion, and a live-Ollama stream
test that asks a local model to echo a token (this session swapped the
loaded model while running experiments).

Model: claude-opus-5[1m]
Harness: lody
The remaining four review findings.

**A declined walk spent the minute's allowance.** `shouldFire` recorded
the fire, then `captureEdgeSnapshot` could still bail on its own guards —
a browser, an excluded app, a window that would not resolve. Clicking
around one such app burned all six walks and starved every other app for
the minute. Spending now happens only through `fire(nowMs:walk:)`, which
consumes the budget after the walk reports it happened; the permission
check and the accounting are no longer separately callable, because an
API that asks callers to pair them is one that eventually drifts.

**Unavailable spans were closed at both ends.** A gap ends at the next
observed input — the very event proving the tap was alive again — so
that instant was being reported as unobservable. Spans are half-open
now. A gap that never recovered still covers its horizon inclusively,
which is why that fallback is one past the end rather than the end.

**Materialisation missed slots that a span only reached into.** The work
list was keyed on `at_ms` alone, so a burst from 09:59:58 to 10:00:20
enqueued only the first slot; with nothing else in the second one it was
never frozen, and once the events expired it failed open forever —
silently dropping typing the user did. Every slot a span touches is
enqueued. The test was checked against the unfixed function first: it
fails there, so it tests what it claims to.

Also trimmed the duplicated rationale on `lastClick` now that
`TypingTarget` owns that rule, and recorded the half-open span and the
touched-slot rule in context/acts-join.md (invariant six).

Verified: afterray-store 219/219, shim XCTest 30/30 (the three pacing
tests now exercise the same `fire` path production does, plus a new one
pinning that twenty declined walks leave the budget untouched), Swift
root 340/340. afterrayd unchanged at 128 pass with the two pre-existing
failures (GOP compression ratio; a live-Ollama stream test).

Model: claude-opus-5[1m]
Harness: lody
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant