Skip to content

Commit 7ea4b06

Browse files
philcunliffeclaude
andauthored
Hold sink exports behind a bounded pick-pending marker on fresh enroll (LLP 0093) (#287)
* Fix local-only picker still skipping on a genuinely fresh enroll (issue #281 follow-up) After the #283 reordering the picker ran at the right time but queried through the wrong registry: hyp remote login boots its kernel at dispatch time, before enrollment, and on a first-run box the local config names no plugins. The login process's query-registry snapshot therefore never registers ai_gateway_messages - that registration arrives with @hypaware/ai-gateway, which the org config-control pull enables only after enrollCentralSink installs the daemon. listCapturedDirectories then failed with "unknown dataset" (best-effort null), waitForCapturedDirectories read null as "enumeration cannot run, stop now" (no 30s poll), and the picker took its enumeration_failed durable-hint path on exactly the fresh enroll it exists for. The #283 tests stubbed waitForCaptured/listCandidates, so the gap was never exercised; a re-login on a populated box worked, which is why manual verification passed. Fix: freshenCaptureEnumeration (src/core/cli/remote_commands.js). On the fresh-enroll interactive fork, after waitForClientAttach returns a client, re-boot one fresh kernel (config profile, the same layered resolution any subsequent hyp command runs) and hand its registry's enumeration to the capture wait. One re-boot is sufficient by construction: attach markers are written by the reconciler that runs only after a confirmed config apply, so attach means the pulled central layer is on disk - and the attach handler requires the gateway capability, so that layer enables the plugin. The snapshot-already-has-it case (re-login on a populated box) skips the boot entirely; a failed re-boot or a still-missing dataset falls back to the prior behavior. Best-effort throughout: the refinement never breaks the login it refines (LLP 0072). The handle is disposed (sources.stopAll, mirroring dispatch) after the picker so no boot-started source keeps the login process alive. local_only.js exports CAPTURE_DATASET so the registry check and the enumeration SQL cannot drift. LLP 0069/0080 carry forward-ref notes per the immutable-record convention. Tests: seam-level coverage of the refresh wiring (fresh enumeration is polled, disposed after the picker; null refresh falls back), unit coverage of freshenCaptureEnumeration (snapshot no-op, success, still-missing, boot-throw), and - the #283 lesson - one deliberately unstubbed test that runs the REAL bootKernel against an on-disk central layer naming @hypaware/ai-gateway and asserts enumeration RUNS (empty list, pollable) instead of failing to null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Hold sink exports behind a bounded pick-pending marker on fresh enroll (LLP 0093) Closes the one-time forwarding window the issue #281 reordering left open: after PR #283 deferred the local-only picker past enrollCentralSink, the daemon that enroll installs could attach, backfill, and start exporting rows before the user finished the pick. Withholding is enforced at export-read time, and the server has no retraction path, so rows from a directory the user was about to withhold forwarded once, permanently - narrowing LLP 0069 R6's "not forwarded, even once" on exactly the fresh-enroll path the picker exists for. Decision (LLP 0093): the enrolling login writes a machine-local pick-pending marker (usage-policy/pick-pending.json, co-located with the local-only list) BEFORE enrollCentralSink, so no daemon tick can beat it onto disk; the kernel sink driver skips whole export ticks while the marker is fresh (held: 'pick_pending' in the tick report); the login clears the marker in one finally covering every exit from the fresh-enroll fork. Freshness is mtime with a 10-minute TTL and stale or unreadable markers read as absent, so a crashed or abandoned login can never stall exports indefinitely - the hold is a bounded refinement, never a kill switch (LLP 0072). Held rows stay in the cache and export on the first tick after the pick; watermarks make that a plain catch-up. Enforcement is driver-wide rather than per-sink: the driver cannot know which sinks leave the machine without a new registration concept, and briefly deferring a local sink is harmless where one missed forward hold is a permanent leak. Rejected alternatives (backfill-before-forward reordering, off-machine-only gating, unbounded hold) are recorded in the decision doc. Re-logins and --no-forward/query-only logins never write the marker. Tests: marker module (fresh/clear/stale-unlink/injected-now/fail-open), driver hold (fresh marker holds the whole tick, cleared or expired marker exports), and login lifecycle (marker lands before enroll, persists through the pick, cleared on return, on enroll error, and on non-TTY; never written on re-login). Stacked on the issue #281 follow-up fix (fresh-enroll registry refresh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 642d1de commit 7ea4b06

9 files changed

Lines changed: 626 additions & 98 deletions

llp/0069-local-only-dir-selection.spec.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ is **no half-enrolled window** (see [dismiss semantics, LLP 0072 §default](./00
150150
> the capture wait. Best-effort: a failed re-boot falls back to the durable
151151
> hint, never an error. Requirements and R6 semantics are unchanged.
152152
153+
> **Window closed by [LLP 0093](./0093-pick-pending-export-hold.decision.md).**
154+
> The one-time backfill forwarding window the first #281 note deferred is now
155+
> held shut: the enrolling login writes a bounded pick-pending marker before
156+
> `enrollCentralSink`, and the sink driver skips export ticks while it is
157+
> fresh, so R6's "not forwarded, even once" holds again on the auto-daemon
158+
> fresh-enroll path (bounded by the marker's TTL).
159+
153160
The picker only makes sense on an **enrolling** login (one that provisions or
154161
already has a central sink). A `--no-forward` / query-only login
155162
([LLP 0063 D3](./0063-login-auto-provision-forward-sink.decision.md)) forwards

llp/0080-local-only-dir-selection.design.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,14 @@ enrollment it refines.
338338
> of this design is unchanged. See
339339
> [LLP 0069 §trigger, the follow-up note](./0069-local-only-dir-selection.spec.md#trigger).
340340
341+
> **Extended-by [LLP 0093](./0093-pick-pending-export-hold.decision.md)
342+
> (export hold during the pick).** The one-time backfill forwarding window
343+
> both notes above describe is now closed: a bounded pick-pending marker
344+
> (`usage-policy/pick-pending.json`, written by the enrolling login before
345+
> `enrollCentralSink`, cleared on every exit from that fork) holds sink
346+
> driver ticks while fresh, restoring R6's "not forwarded, even once" on the
347+
> auto-daemon fresh-enroll path.
348+
341349
## CLI: the durable authoring path {#cli}
342350

343351
`hyp ignore` / `hyp unignore` / `hyp ignore --check`
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# LLP 0093: fresh-enroll exports hold behind a bounded pick-pending marker
2+
3+
**Type:** Decision
4+
**Status:** Accepted
5+
**Systems:** Sinks, CLI, Usage-Policy
6+
**Author:** Phil / Claude
7+
**Date:** 2026-07-08
8+
**Related:** LLP 0069, LLP 0071, LLP 0072, LLP 0080, LLP 0044, LLP 0063
9+
10+
> Closes the one-time forwarding window the issue #281 reordering left open
11+
> (recorded as the "Revisited-by #281" notes in
12+
> [LLP 0069 §trigger](./0069-local-only-dir-selection.spec.md#trigger) and
13+
> [LLP 0080](./0080-local-only-dir-selection.design.md)), restoring R6's
14+
> "not forwarded, even once" on the auto-daemon fresh-enroll path.
15+
>
16+
> @ref LLP 0069 [implements] - restores the R6 ordering guarantee the #281 fix narrowed.
17+
> @ref LLP 0072 [constrained-by] - the hold must stay a bounded refinement; it can never block or fail the login, and an unanswered picker can never become a kill switch.
18+
19+
## Context
20+
21+
The #281 fix (PR #283) moved the local-only picker after `enrollCentralSink`
22+
so it enumerates a populated cache. That reintroduced a window: the daemon the
23+
enroll installs attaches clients and backfills history, and its central sink
24+
can start exporting those backfilled rows **before the user finishes the
25+
pick**. Withholding is enforced at export-read time (LLP 0080 export seam), so
26+
rows from a directory the user was *about to* mark local-only forward once -
27+
and the server has no retraction path, so that one-time leak is permanent.
28+
This narrowed LLP 0069 R6's "recorded here, never forwarded - not even once"
29+
to "withheld from every export tick after the pick" on exactly the
30+
fresh-enroll path the picker exists for.
31+
32+
## Decision
33+
34+
**The enrolling login writes a machine-local pick-pending marker before
35+
`enrollCentralSink`, and the kernel sink driver skips whole export ticks while
36+
that marker is fresh.**
37+
38+
- **Marker**: `usage-policy/pick-pending.json` under `HYP_HOME` state,
39+
co-located with the local-only list (LLP 0071) whose first read it guards.
40+
Content is debug metadata only; freshness is the file's mtime, so a torn
41+
write can neither wedge nor extend the hold.
42+
- **Lifecycle is login-owned**: written on the fresh-enroll fork only (the
43+
only fork where a first backfill can race the pick - a re-login edits a
44+
list whose daemon is already forwarding, LLP 0080 editor semantics),
45+
*before* `enrollCentralSink` so no daemon tick can beat it onto disk;
46+
cleared in one `finally` covering every exit from that fork (pick landed,
47+
cancelled, non-TTY skip, enroll error). Both write and clear are
48+
best-effort: a failed write degrades to the pre-hold behavior, a failed
49+
clear leaves the TTL as backstop; neither ever fails the login.
50+
- **Enforcement is driver-wide**: `createSinkDriver.tick` checks the marker
51+
once per tick and, while fresh, exports nothing (`held: 'pick_pending'` in
52+
the tick report, one info log). Held rows sit in the cache and export on
53+
the first tick after the pick; watermarks (LLP 0040) make that a plain
54+
catch-up, nothing is dropped.
55+
56+
### The hold is always bounded {#bounded}
57+
58+
The marker expires on its own: `isPickPending` treats a marker older than
59+
**10 minutes** (`PICK_PENDING_TTL_MS` - comfortably past the login's two 30s
60+
bounded waits plus a human actually reading the picker) as absent and
61+
opportunistically unlinks it. A stat failure also reads as absent. This is
62+
deliberately the opposite polarity of the local-only *list* (which fails
63+
loudly when unreadable, LLP 0080 #fail-safe): the list is the privacy signal,
64+
the marker is only a timing hint, and failing closed on it would turn a
65+
crashed login or a corrupt file into an unbounded export outage that only a
66+
hand-deleted file could end. A privacy hold that cannot expire is a kill
67+
switch, and LLP 0072 already decided the picker never gets one.
68+
69+
## Alternatives rejected
70+
71+
- **Backfill locally before the forwarding daemon starts** (the other option
72+
the #281 note floated). Reordering does not actually close the window: the
73+
pick needs a human, and the daemon would still forward the moment backfill
74+
completed if the user had walked away. It also delays *all* forwarding
75+
behind a possibly multi-minute backfill and rearranges the daemon
76+
lifecycle (LLP 0044 attach-first ordering) for no guarantee.
77+
- **Gate only off-machine sinks**. The driver cannot know which sinks leave
78+
the machine without a new registration concept (an `offMachine`
79+
declaration every future forward sink must remember), and a plugin-side
80+
convention is exactly the kind of seam a new sink forgets. Holding every
81+
sink for a bounded window is harmless - a local sink's rows stay in the
82+
cache and export minutes later - where one missed forward hold is a
83+
permanent leak. One enforcement point in core, where local-only policy
84+
already lives, wins.
85+
- **Unbounded hold until the pick lands**. See [#bounded](#bounded).
86+
87+
## Consequences
88+
89+
- R6's "not forwarded, even once" holds again on the auto-daemon
90+
fresh-enroll path, provided the pick concludes within the TTL; a user who
91+
ponders the picker past 10 minutes reverts to the narrowed guarantee
92+
(withheld from every tick after the pick), never to an error.
93+
- Every sink, including local ones, pauses up to the pick duration (TTL-capped)
94+
on a fresh enroll only. No steady-state cost: the marker exists for at most
95+
one login's duration per machine lifetime, and a per-tick `stat` of a
96+
usually-absent file otherwise.
97+
- `hyp remote login --no-forward` and query-only logins never write the
98+
marker (they never reach the enrolling fork).

0 commit comments

Comments
 (0)