Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/app-kit/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,13 +538,27 @@ Silent background context for LLM — content appears in the next user-initiated
| `setDefaultSlot(slotId)` | `void` | Auto-flush pending context on sendMessage |
| `pendingContextCount` | `number` | Number of buffered context entries |

Options: `{ source?: string, ephemeral?: boolean, maxAge?: number }`
Options: `{ source?: string, ephemeral?: boolean, maxAge?: number, contextKey?: string }`

> **`ephemeral` defaults to `true`, so an omitted flag keeps an entry MEMORY-ONLY.** That is the pre-existing contract and this change does not alter it: a caller that omits the flag still gets memory-only content, and nothing posted without the flag begins reaching disk. Durability is opt-IN — pass `ephemeral: false` to have an entry written to the session metadata line and re-seated after a tab close or a gateway restart. Only the literal boolean `false` opts in.

> **`contextKey` makes a repost idempotent.** A POST naming a key an UNEXPIRED entry the same `source` already holds is a no-op: the response is the ordinary `{ ok, pending }` and no second entry queues. It exists because the queue now survives a close, so a caller that re-posts after a reload would otherwise deliver the same content twice with no recovery until the TTL. Scoped by `source`, so the key namespace belongs to the caller that set it. The match covers every copy this slot still owes — queued, drained-and-in-flight, and parked over the ceiling — so a repost during a drain is suppressed too; another session's held content is excluded. An EXPIRED entry never suppresses: that entry is discarded by the drain, so matching it would acknowledge a repost whose content never reaches the model. Omit it and nothing is deduplicated: two identical posts are two entries, as before.
>
> **Constraint** (400 on violation): `contextKey` must be a string of ≤64 characters with no control characters or newlines — the same limit and shape `source` carries, and it is REFUSED (`code: "context_key_too_long"` / `"invalid_context_key"`) rather than truncated. Truncation would alias two distinct keys sharing a prefix onto one, so the second post would match the first and be acknowledged without queueing.

> **`ephemeral` keeps an entry MEMORY-ONLY.** An entry posted with it queues, drains and expires exactly like any other, but it is withheld from the session metadata line, so it does not survive a close or a gateway restart and never lands on disk. Every other entry IS persisted and restored on reopen. Use `maxAge` to bound how long an entry may live while it is queued — that is the field the drain enforces. Note that an ephemeral entry's content still reaches the transcript once it is actually injected into a turn, so this bounds the QUEUE's durability, not the conversation's.

**Constraints** (400 on violation):
- `source`: ≤64 chars, no control characters or newlines; whitespace-trimmed (a padded label and its bare form share one per-source cap bucket)
- `maxAge`: must be a finite positive number (rejects boolean, NaN, Infinity, ≤0); omit or pass null for no expiry
- `maxAge`: must be a finite positive number (rejects boolean, NaN, Infinity, ≤0). Omit or pass null for no per-entry expiry — subject to a **queue-level backstop of 7 days**, which exists because a full queue now refuses rather than evicting: without it a slot holding no-`maxAge` entries that never takes another turn would answer 429 on every later post forever.
- `content`: must be a non-empty string, ≤40,000 chars

**Queue capacity** (429 on refusal, `code: "context_not_queued"`):
- The 429 covers two causes: the queue cannot fit the entry, or the entry's own `maxAge` elapsed between validation and queueing (possible only with a sub-second TTL, since both happen inside one request). The response wording says "could not be queued" rather than naming a full queue, because the second cause refuses with the queue empty — retrying after a drain would not help there, whereas splitting the content or lengthening the TTL would.
- **A 200 means ACCEPTED, not yet on disk.** An entry posted and then lost to a crash before the next save is possible — the same best-effort durability every other slot mutation has. What makes it survive an ordinary tab close and a gateway restart: queuing an entry marks the slot dirty and satisfies the flush's message-less guard, so the **periodic** flush writes a tab holding nothing but queued context; the **shutdown** flush persists a message-less slot outright; and the tab-close path forces a durable save of its own. Neither path drops the queue: both the flush guard and the save's own early return require an empty queue as well as an empty window, so a queued entry keeps the save running through to the write. Durability is bounded by what one session metadata line can carry — an entry that does not fit alongside what is already queued (including the context halves of held notes, which are promoted later) is REFUSED rather than accepted and dropped at save time, because a refusal is visible and retryable whereas silent truncation after a 200 is neither.
- Retry after the next user turn drains the queue, or split the content into smaller entries. The queue is bounded on BOTH dimensions: roughly one worst-case 40,000-char entry by bytes, and 50 entries by count. Reaching either ceiling refuses the new entry — an already-accepted entry is never evicted to make room.
- `/note` does not 429 here. Its VISIBLE line is still written and the response reports `contextSkipped: true`, so the audit record the caller came for survives even when the context half cannot be queued.

**Ownership** (404 on refusal; applies to app callers — a dashboard caller is unrestricted):
- An app may only target a slot it owns, and a slot carrying no app scope is refused as well.
- Owning the slot is not sufficient: an app is refused when the slot's session is linked elsewhere — a cron result or workflow injection holding that binding — because both writes land in the linked session, so slot ownership alone would otherwise reach a conversation the app has no claim on.
Expand All @@ -554,11 +568,15 @@ Options: `{ source?: string, ephemeral?: boolean, maxAge?: number }`

`POST /api/chat/slots/{slot}/note` drops a short declarative line into a chat that is both visible in the transcript immediately and known to the agent on the user's next message — without firing an LLM turn. Context injection alone is silent; a transcript append alone is invisible to the model, because a live provider forwards only the new user message. The note endpoint does both writes against one slot.

Body: `{ content, source?, maxAge?, ephemeral? }`. A note always does both writes -- there is no visible-only or context-only mode. The visible line is appended as `role: "inject"` with `cls: "reconcile-note"`, and its content is redacted (credentials, exfiltration URLs) before it reaches the transcript. `maxAge` defaults to 24h for the context half when the key is omitted, so a note nobody follows up on expires instead of attaching to an unrelated message later. An explicit null means no expiry, the same as it does on `/context` — the two endpoints share the field and do not give it opposite meanings. The same `source`/`maxAge`/`content` constraints above apply.
Body: `{ content, source?, maxAge?, ephemeral? }`. `ephemeral` carries the SAME contract here as on `/context` — it defaults to `true`, so the context half of a note stays MEMORY-ONLY unless the caller passes `ephemeral: false` explicitly. A note always does both writes -- there is no visible-only or context-only mode. The visible line is appended as `role: "inject"` with `cls: "reconcile-note"`, and its content is redacted (credentials, exfiltration URLs) before it reaches the transcript. `maxAge` defaults to 24h for the context half when the key is omitted, so a note nobody follows up on expires instead of attaching to an unrelated message later. An explicit null means no expiry, the same as it does on `/context` — the two endpoints share the field and do not give it opposite meanings. The same `source`/`maxAge`/`content` constraints above apply.

Returns `{ ok, appended, visibleDeferred, deliveryConditional, contextSkipped, pending }`. When the source's per-source context cap is full the request is **not** rejected: the visible line is still written and `contextSkipped` is true, because the cap protects the context queue rather than the transcript. If a turn is already running the note is held until that turn ends -- `appended` is false and `visibleDeferred` is true -- so that it lands on the next turn rather than the one it was written during. Ordering is preserved, and `deliveryConditional` is true whenever a note is held -- because a hold is delivered only if the slot still routes to the SAME session when the turn ends. An unbound slot can acquire a foreign binding while the note waits (a cron result or workflow injection claims an empty `linked_session_key` with no running gate), and both the transcript path and the next turn's session resolve that binding at flush time rather than at the POST. When that happens BOTH halves of the note are dropped rather than retargeted, because writing them would surface content authorized for one conversation inside another; the drop is recorded in the security-event log. So a 200 with `visibleDeferred: true` promises ordering against the running turn, not that the note will certainly be written. `pending` counts held entries as well as queued ones.

**A 200 for a held note is a durable acknowledgement — for a slot that has a durable identity.** The hold is persisted verbatim (both halves, the silent context included) into the slot's own session metadata *before* the 200 is returned, replayed into the hold by both slot-restore paths after a gateway restart, and retired by the save that commits the delivered rows -- so a note accepted with `visibleDeferred: true` survives a restart and is delivered, unaltered, on the first turn after it. Two edges keep the original gateway-lifetime meaning instead: a memory-only deployment (no conversation log at all), and a slot that has never been persisted (no metadata line to attach the hold to -- such a tab does not itself survive a restart, so there is no restored slot the note could outlive). Do **not** re-post a held note after a restart; the restored hold delivers it, and a re-post would put the same line in the transcript twice. Three boundary refusals protect that promise: a note posted during a running turn is capped at 4,000 characters (`413`, code `deferred_note_too_large` -- shorten it or wait for the turn to end), a slot whose durable hold is full answers `429 deferred_notes_full` until its rows are saved, and a slot that is rebound to another session while the hold is persisting answers the endpoint's uniform `404` -- the note was neither delivered nor made durable (a note the turn-end flush drops at that same rebind seam takes this `404` too; the 200 stands only when the note observably exists in a delivered row or the durable hold). The one retry-the-same-request signal is a `503` with code `deferred_note_persist_failed`, which means the durable write itself failed and the note was **not** accepted. The queued context of an *immediate* (non-held) note still behaves exactly as `/context`'s queue always has -- in memory, for this gateway lifetime. Note the retention consequence of durability: a HELD note's context half -- the trusted-caller channel, which is deliberately not redacted -- now lives on disk in the session metadata until delivery or retirement, where an immediate note's context only ever lived in memory.
**A 200 for a held note is a durable acknowledgement — for a slot that has a durable identity.** The hold is persisted verbatim (both halves, the silent context included) into the slot's own session metadata *before* the 200 is returned, replayed into the hold by both slot-restore paths after a gateway restart, and retired by the save that commits the delivered rows -- so a note accepted with `visibleDeferred: true` survives a restart and is delivered, unaltered, on the first turn after it. Two edges keep the original gateway-lifetime meaning instead: a memory-only deployment (no conversation log at all), and a slot that has never been persisted (no metadata line to attach the hold to -- such a tab does not itself survive a restart, so there is no restored slot the note could outlive). Do **not** re-post a held note after a restart; the restored hold delivers it, and a re-post would put the same line in the transcript twice. Three boundary refusals protect that promise: a note posted during a running turn is capped at 4,000 characters (`413`, code `deferred_note_too_large` -- shorten it or wait for the turn to end), a slot whose durable hold is full answers `429 deferred_notes_full` until its rows are saved, and a slot that is rebound to another session while the hold is persisting answers the endpoint's uniform `404` -- the note was neither delivered nor made durable (a note the turn-end flush drops at that same rebind seam takes this `404` too; the 200 stands only when the note observably exists in a delivered row or the durable hold). The one retry-the-same-request signal is a `503` with code `deferred_note_persist_failed`, which means the durable write itself failed and the note was **not** accepted. The queued context of an *immediate* (non-held) note still behaves exactly as `/context`'s queue always has -- in memory, for this gateway lifetime -- unless the caller opts in to durability with `ephemeral: false`, described below. Note the retention consequence of durability: a HELD note's context half -- the trusted-caller channel, which is deliberately not redacted -- now lives on disk in the session metadata until delivery or retirement, where an immediate note's context only ever lived in memory.

The **queued context** half is durable when the caller opts in with `ephemeral: false`. It is persisted into the session's metadata line and re-seated when the session is restored, so it survives closing the tab and survives a gateway restart. `maxAge` continues to run while the session is closed (wall-clock), so a long-closed session does not reopen holding stale background context, and an entry with no expiry comes back for up to seven days (`DEFAULT_CONTEXT_TTL_SECS`), the backstop that bounds a seat the caller never bounded itself. An entry that does not opt in stays memory-only for this gateway lifetime, which is the pre-existing contract; opting in is the deliberate change.

**Compatibility — a caller that OPTS IN to durability with `ephemeral: false` and re-posts on reconnect will double-inject, unless it also passes `contextKey`.** The old recipe was "if you need a note to survive a restart, re-post it". For an entry that opts in, the restored copy and the re-post both drain into the next user message, so the model sees the content twice. A POST carrying a `contextKey` is refused as a no-op while the first copy is still pending, and a default (memory-only) entry is never restored to be duplicated — so a caller that changes nothing is unaffected. Callers that adopt `ephemeral: false` should drop the re-post, or adopt a key. Re-posting remains the only way to guarantee a *visible* line that was held when the gateway went down.

### Proxy Authentication (Server-side)

Expand Down
28 changes: 27 additions & 1 deletion docs/system-specs/modules/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,19 @@ state a close compensates is not all scoped the same way.
conversation's own MONOTONE once-flags (`auto_tagged`, `human_seen`,
`channel_origin`, `channel_folder_filed`) are set and never cleared, so two writers
on one transcript cannot disagree about them in a way that outlives the pair; they
stay as written. Deferring to disk is deliberately not
stay as written. `pending_context` is the ONE slot-owned key the rows-only write does not
defer. It falls inside `ROWS_ONLY_DEFERRED_META_KEYS` by construction, being the difference
of a set it belongs to, but deferring it would drop queued entries the API already answered
200 for and which have no other durable home on that file — so the branch UNIONS instead,
disk copy first, deduped by each entry's `ctxId`. Its ownership is otherwise ordinary, and
deliberately so: omitting the key is what durably empties a delivered queue, which is why it
cannot simply be carried forward. That leaves one asymmetry the full save has to respect —
omission may only speak for entries this slot actually hydrated, tracked per slot as the
accounted-for `ctxId` set. An entry a same-key handover wrote AFTER this slot hydrated is
absent from its export through ignorance rather than delivery, so the full save preserves it
rather than reading its absence as a clear; and the accounted-for set records this slot's OWN
committed ids only, never the merged line's, or another holder's entry would be claimed and
then cleared on the next save. Deferring to disk is deliberately not
the same as deriving the line from the replacement — a recreate that published
nothing has no metadata to protect, and re-deriving from it would ERASE a real
title and filing the two slots' shared conversation has; leaving the line alone is
Expand Down Expand Up @@ -856,6 +868,20 @@ state a close compensates is not all scoped the same way.
runs and the open-shaped write erases it. The failure arms take the same route in place of the
restore they skip: a store that rejected the `closed=True` write can still
accept the next one, and a lock lost to the recreate is exactly that case.

**Why an OVERFLOW sidecar stands while a sidecar for the PRIMARY copy does not.** The
rejection above is about where an entry's one durable copy lives: split the primary copy
across the metadata line and a second file and two writes must agree, so a crash between
them leaves an entry either double-injected or silently gone, and every reader needs both
files to answer a question the line alone should answer. The overflow file makes neither
trade, because it is not a second home for the same entry. The line remains the primary
copy for every entry that fits it, and the sidecar holds only what a save could not put
there — a set the line, by construction, does not carry. What makes the pair safe to read
is that the fold dedups by `ctxId`, so an entry reachable from both surfaces resolves to
ONE entry rather than two injections, and the post-commit reconcile prunes the copy the
commit made redundant. The invariant the rejection protects therefore still holds: exactly
one durable copy of an entry exists at any instant. A save with nothing over the budget and
no existing spill writes no sidecar at all, so the common path is unchanged by it.
- **A drain that fails is reported, not swallowed.** `_persist_handover_tail`
returns whether rows were owed and reached disk, and every caller honours it —
because this frame is the last reference to those rows, so nothing will retry and
Expand Down
Loading
Loading