From 9ce2e3e5b9568ff0003e1096e9e7f57460d80b24 Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Fri, 3 Jul 2026 15:15:56 -0700 Subject: [PATCH] update llp to include hypignore by session id --- llp/0049-hypignore-usage-policy.spec.md | 6 +- ...50-ignore-enforced-in-adapters.decision.md | 8 +- ...usage-policy-future-extensions.decision.md | 45 +++-- llp/0062-session-opt-out.spec.md | 179 ++++++++++++++++++ 4 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 llp/0062-session-opt-out.spec.md diff --git a/llp/0049-hypignore-usage-policy.spec.md b/llp/0049-hypignore-usage-policy.spec.md index 8b5c50a3..cd3ba35e 100644 --- a/llp/0049-hypignore-usage-policy.spec.md +++ b/llp/0049-hypignore-usage-policy.spec.md @@ -149,8 +149,10 @@ dotfile should not be the only path: ([LLP 0031](./0031-layered-config.decision.md)) or pushed by central ([LLP 0036](./0036-central-config-driven-client-actions.decision.md)). Org-forced policy is a future concern tied to `local-only`. -4. **Ephemeral per-session opt-out** is not in V1 — see - [LLP 0051](./0051-usage-policy-future-extensions.decision.md). +4. **Ephemeral per-session opt-out** is a separate mechanism, not part of this + folder spec. It is specced in [LLP 0062](./0062-session-opt-out.spec.md) + (session-scoped, in-memory, keyed on `session_id`), promoted from the deferred + sketch in [LLP 0051](./0051-usage-policy-future-extensions.decision.md#session-opt-out). ## Requirements {#requirements} diff --git a/llp/0050-ignore-enforced-in-adapters.decision.md b/llp/0050-ignore-enforced-in-adapters.decision.md index ef036e86..dd0a6153 100644 --- a/llp/0050-ignore-enforced-in-adapters.decision.md +++ b/llp/0050-ignore-enforced-in-adapters.decision.md @@ -5,7 +5,7 @@ **Systems:** Gateway, Plugins, Core **Author:** Phil / Claude **Date:** 2026-06-29 -**Related:** LLP 0012, LLP 0016, LLP 0049 +**Related:** LLP 0012, LLP 0016, LLP 0049, LLP 0062 > The `.hypignore` capture-seam drop ([LLP 0049](./0049-hypignore-usage-policy.spec.md)) > lives in the `@hypaware/claude` and `@hypaware/codex` adapters — the only @@ -91,3 +91,9 @@ worse coupling than both importing core. - The gateway source and recorder are not modified. - A future caller-supplied `cwd` for raw-proxy traffic would add a *new* call site that reuses the same core matcher — no change to this decision. +- The ephemeral per-session opt-out ([LLP 0062](./0062-session-opt-out.spec.md)) + reuses this same adapter drop with a *different key*: it matches on the + `session_id` the adapter resolves instead of on `cwd`, and returns the same + `USAGE_POLICY_DROP` sentinel. That mechanism adds a gateway *control route* and + an in-memory set of opaque `session_id` strings, but the gateway still performs + no drop and interprets no identity, so this decision holds unchanged. diff --git a/llp/0051-usage-policy-future-extensions.decision.md b/llp/0051-usage-policy-future-extensions.decision.md index 9bab8ccf..2cd3be21 100644 --- a/llp/0051-usage-policy-future-extensions.decision.md +++ b/llp/0051-usage-policy-future-extensions.decision.md @@ -5,13 +5,18 @@ **Systems:** Gateway, Sinks, Plugins **Author:** Phil / Claude **Date:** 2026-06-29 -**Related:** LLP 0014, LLP 0029, LLP 0030, LLP 0039, LLP 0049, LLP 0050 +**Related:** LLP 0014, LLP 0029, LLP 0030, LLP 0039, LLP 0049, LLP 0050, LLP 0062 > Two capabilities deliberately **out of V1 scope** for the hypignore mechanism > ([LLP 0049](./0049-hypignore-usage-policy.spec.md)), captured so the V1 design > stays forward-compatible with them: the `local-only` usage class, and the > ephemeral per-session opt-out. This document records *that they are deferred* > and *the seam each will use*, not a commitment to build them. +> +> **Update (2026-07-03):** the ephemeral per-session opt-out has since been +> promoted to its own spec, [LLP 0062](./0062-session-opt-out.spec.md); this +> document retains it only as historical context ([§session-opt-out](#session-opt-out)). +> `local-only` remains deferred. ## Why this is written now @@ -49,33 +54,37 @@ encounters it resolves to `ignore` via the [fail-safe](./0049-hypignore-usage-policy.spec.md#fail-safe), so no data marked `local-only` is ever exported by a version that cannot honor the restriction. -## Deferred 2: ephemeral per-session opt-out {#session-opt-out} +## Promoted: ephemeral per-session opt-out {#session-opt-out} -**Intent.** "Don't record *this conversation*" — a temporary, in-memory, +**Promoted to [LLP 0062](./0062-session-opt-out.spec.md) on 2026-07-03.** The +authoritative spec for this mechanism now lives there; the notes below are the +original deferred sketch, kept for provenance. + +**Intent.** "Don't record *this conversation*": a temporary, in-memory, session-scoped drop that does not write a committable file and reverses when the session ends. This is what the `hypaware-ignore` / `hypaware-unignore` skills -*originally* advertised (`POST` / `DELETE /_hypaware/ignore/session`), against an -endpoint that was never built. +advertise (`POST` / `DELETE /_hypaware/ignore/session`), against an endpoint that +was never built. **Why it is distinct from `.hypignore`.** It is a different product: *session*- scoped and *ephemeral*, versus the folder `.hypignore` which is *directory*-scoped and *persistent/committable* (it stops recording the whole tree for everyone). Repointing the skills at `.hypignore` would over-broaden "ignore this session" -into "ignore this repo forever," so the session opt-out stays a separate future +into "ignore this repo forever," so the session opt-out stays a separate mechanism. -**Seam.** Unlike folder rules, a session opt-out keys off the **`session_id`**, -which the gateway *does* see inline on the wire — so this one **can** live in the -gateway (a small in-memory set + the control route), and does **not** contradict -[LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)'s "gateway is -cwd-blind": session-id is not cwd. - -**V1 interim.** The two skills are reconciled to be honest — they point at -`hyp ignore` / `.hypignore` and state plainly that per-session in-memory opt-out -is not yet available — rather than POSTing to a 404. +**Seam (refined in [LLP 0062](./0062-session-opt-out.spec.md#enforcement)).** The +original sketch put the whole thing in the gateway. LLP 0062 splits it: the +**control route + in-memory set** are gateway-resident (the gateway holds opaque +`session_id` strings it never interprets), but the **drop itself stays in the +client adapter projector**, keyed on the `session_id` the adapter already +resolves and returning the same `USAGE_POLICY_DROP` sentinel as the `.hypignore` +drop. That keeps [LLP 0050](./0050-ignore-enforced-in-adapters.decision.md) +intact rather than moving provider awareness into the gateway. ## Status -Both are **Draft / not scheduled.** When either is built, this document either -spawns a dedicated spec/decision and is superseded, or is promoted in place. It -exists today so V1's reviewers can confirm the V1 design does not foreclose them. +`local-only` ([§local-only](#local-only)) is **Draft / not scheduled**. The +ephemeral per-session opt-out ([§session-opt-out](#session-opt-out)) has been +**promoted to [LLP 0062](./0062-session-opt-out.spec.md)**. This document exists +today so V1's reviewers can confirm the V1 design does not foreclose either. diff --git a/llp/0062-session-opt-out.spec.md b/llp/0062-session-opt-out.spec.md new file mode 100644 index 00000000..f716eebc --- /dev/null +++ b/llp/0062-session-opt-out.spec.md @@ -0,0 +1,179 @@ +# LLP 0062: ephemeral per-session opt-out + +**Type:** Spec +**Status:** Accepted +**Systems:** Gateway, Plugins, Sources +**Author:** Brendan / Claude +**Date:** 2026-07-03 +**Related:** LLP 0016, LLP 0030, LLP 0049, LLP 0050, LLP 0051 + +> "Don't record *this conversation*": a temporary, in-memory, session-scoped +> drop that writes no committable file and reverses when the session ends or on +> `/hypaware-unignore`. The `@hypaware/claude` `hypaware-ignore` / +> `hypaware-unignore` skills already specify the contract (`POST` / `DELETE` +> `/_hypaware/ignore/session`, keyed on `session_id`); this spec makes the code +> honor it. Promotes [LLP 0051 §session-opt-out](./0051-usage-policy-future-extensions.decision.md#session-opt-out) +> from deferred to specced. Distinct from the folder-scoped `.hypignore` +> ([LLP 0049](./0049-hypignore-usage-policy.spec.md)). + +## Motivation + +The `hypaware-ignore` / `hypaware-unignore` skills advertise a clear, correct +contract: stop recording the current conversation by `POST`ing its session id to +`/_hypaware/ignore/session`, and reverse with `DELETE`. **The contract is right; +the code was never built.** The endpoint, the in-memory drop set, and the +gateway control path they depend on do not exist, so a user invoking the skill +today hits a route nothing serves ([issue #220](https://github.com/hyparam/hypaware/issues/220)). + +This spec closes that gap without changing the skills. The skills are the +contract; the implementation is specified here. + +## Relationship to `.hypignore` {#vs-hypignore} + +This is a **different product** from the folder mechanism, not a variant of it: + +| | `.hypignore` ([LLP 0049](./0049-hypignore-usage-policy.spec.md)) | session opt-out (this spec) | +|---|---|---| +| Scope | a directory subtree | one client session | +| Lifetime | persistent, committable | ephemeral, in-memory | +| Audience | the whole tree, for everyone | just the current conversation | +| Match key | `cwd` (ancestor walk) | `session_id` | + +Repointing the skills at `.hypignore` would over-broaden "ignore this session" +into "ignore this repo forever," which is why the two stay separate mechanisms. +They are also **independent at enforcement time**: either match suppresses; they +do not merge or interact. + +## The match key is `session_id` {#scope} + +The drop keys on **`session_id`**, the always-present partition key +([LLP 0030](./0030-session-id-partition-key.decision.md)). What that scope means +differs per client, and the difference is load-bearing: + +| Client | `session_id` | `conversation_id` | A `session_id` drop suppresses | +|---|---|---|---| +| Claude | the whole session (`x-claude-code-session-id` / `metadata.user_id.session_id`) | `null` (the session *is* the thread) | exactly this conversation | +| Codex | the session container (`metadata.session_id` / `session-id` header) | the thread within it | **all** threads in that session | + +For **Claude**, `session_id == the conversation`, so the drop is exact. For +**Codex**, `session_id` is a container of multiple `conversation_id` threads, so +a `session_id` drop is broader than "this conversation": it suppresses every +thread in the session. Per-thread (`conversation_id`) granularity is a +[non-goal](#non-goals); the over-drop is latent, not live, because the only +opt-out skill today is Claude-only and Claude has no `conversation_id`. + +## Enforcement: control route in the gateway, drop in the adapter {#enforcement} + +The naive reading of "gateway-resident" (as [LLP 0051](./0051-usage-policy-future-extensions.decision.md#session-opt-out) +originally phrased it) would have the gateway itself perform the drop. That would +force the gateway to obtain `session_id` from the request, either by parsing the +provider-specific body (`metadata...session_id`) or by trusting a +provider-specific header. Both push provider awareness into the gateway, which +[LLP 0050](./0050-ignore-enforced-in-adapters.decision.md) forbids, and the +header path risks diverging from the body-first canonical id the row is actually +stamped with. + +**So the work splits across the same seam `.hypignore` already uses:** + +1. **Control surface: gateway.** The gateway serves `POST` / `DELETE + /_hypaware/ignore/session` and holds an in-memory set of **opaque session-id + strings**. It never interprets them: to the gateway they are meaningless + tokens toggled on and off. This is provider-agnostic and does not violate + [LLP 0050](./0050-ignore-enforced-in-adapters.decision.md) (`session_id` is + not `cwd`, and the gateway inspects nothing about the exchange). + +2. **Drop: client adapter exchange projector.** The adapter already resolves the + canonical `session_id` it stamps on the row (`resolveClaudeSessionId` for + Claude, `metadata.session_id` for Codex). When that `session_id` is in the + ignored set, the projector returns the terminal `USAGE_POLICY_DROP` sentinel, + exactly as the `.hypignore` `cwd` drop does + ([LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)). The gateway + dispatcher already recognizes that sentinel, persists nothing, and logs an + intentional usage-policy drop rather than a `no_projector_match` miss. + +This is the key reconciliation: **the session opt-out does not overturn +[LLP 0050](./0050-ignore-enforced-in-adapters.decision.md); it adds a second +match key (`session_id`) feeding the same adapter drop.** `.hypignore` matches on +`cwd`; session opt-out matches on `session_id`; both terminate in +`USAGE_POLICY_DROP` returned from the adapter. Only the *control surface* is new +and gateway-resident. + +Matching on the adapter's own resolved `session_id` (not a gateway header peek) +also guarantees the dropped identity is the recorded identity: the skill sends +`CLAUDE_CODE_SESSION_ID`, which is the same value the Claude adapter resolves and +stamps, so the set membership test cannot drift from what would have been +written. + +### Gateway control-path concept {#control-path} + +Today `ai-gateway`'s source compiles only an *upstream* routing table (which API +to proxy to) and treats every inbound request as proxiable +(`ai-gateway/src/source.js`, `proxy.js`). Serving the endpoint requires a new +concept: requests under the reserved **`/_hypaware/`** prefix are recognized as +**local control requests**, handled in-process, and never forwarded upstream. +The prefix is reserved for this and future control endpoints. + +## Ephemerality {#ephemeral} + +The ignored-session set lives only in the running gateway's memory. A gateway +restart drops the set, and recording silently resumes for the affected session: +the skill notes already state this and advise re-running `/hypaware-ignore` after +a restart. This is accepted, not a defect: the opt-out is deliberately a +lightweight session convenience, and the committable, durable mechanism is +`.hypignore` ([LLP 0049](./0049-hypignore-usage-policy.spec.md)). + +## Non-goals {#non-goals} + +1. **Per-thread (`conversation_id`) granularity.** Deferred. `conversation_id` + is `null` for Claude and, for Codex, is computed during projection from a + provider-specific body, so keying on it would pull provider parsing into the + gateway and contradict [LLP 0050](./0050-ignore-enforced-in-adapters.decision.md). + If a Codex opt-out ever needs true per-thread grain, it follows the + adapter-enforcement model keyed on `conversation_id` and is specced + separately; it does not motivate moving the drop into the gateway. Until + then, a Codex `session_id` drop over-drops to the whole session + (see [scope](#scope)). +2. **No persistence or committable form.** That is `.hypignore` + ([LLP 0049](./0049-hypignore-usage-policy.spec.md)). This mechanism writes no + file and does not survive restart. +3. **Prospective-only; no purge.** Only exchanges arriving while the session is + ignored are dropped. Rows already recorded before the opt-out are left + untouched; retroactive deletion is out of scope, matching + [LLP 0049](./0049-hypignore-usage-policy.spec.md#prospective-only). +4. **No central/config interaction.** The opt-out is a local, in-memory toggle. + It is not layered config ([LLP 0031](./0031-layered-config.decision.md)) and + is not pushed by central. + +## Requirements {#requirements} + +- **R1.** `POST /_hypaware/ignore/session` with `{"session_id": "..."}` MUST add + that id to the gateway's in-memory ignored-session set; `DELETE` with the same + body MUST remove it. Both MUST be idempotent and MUST return the current total + count (the skill reads `.total`). +- **R2.** The gateway MUST recognize `/_hypaware/*` as local control paths and + MUST NOT proxy them upstream (see [control path](#control-path)). +- **R3.** The ignored-session set MUST be in-memory only: no file, no cache + column, lost on gateway restart (see [ephemerality](#ephemeral)). +- **R4.** Enforcement MUST be a capture-seam drop in the client adapter exchange + projector, returning the same `USAGE_POLICY_DROP` sentinel as the `.hypignore` + drop ([LLP 0050](./0050-ignore-enforced-in-adapters.decision.md)), so nothing + is written and the gateway logs an intentional drop, not a projector miss. +- **R5.** The match key MUST be the `session_id` the adapter resolves and stamps + on the row (body-first canonical resolution), NOT a gateway-side header peek, + so the dropped set matches the recorded identity. +- **R6.** The opt-out MUST NOT alter the live LLM call: the response has already + been streamed by projection time, so only persistence is suppressed (matching + [LLP 0049 R2](./0049-hypignore-usage-policy.spec.md#requirements)). +- **R7.** session opt-out and folder `.hypignore` MUST be independent: either + match suppresses; they do not merge. +- **R8.** Tests MUST cover Claude (session equals conversation), Codex (whole + session versus a single thread, documenting the over-drop), and + restart-drops-state. + +## `@ref` annotations code will carry {#refs} + +- The gateway control route and the ignored-session set: + `@ref LLP 0062#control-path [implements]` and `@ref LLP 0062#ephemeral`. +- The adapter projector drop keyed on `session_id`: + `@ref LLP 0062#enforcement [implements]`, alongside the existing + `@ref LLP 0050` on the same drop site.