Skip to content
9 changes: 9 additions & 0 deletions .github/audit-acknowledgements.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"advisory": "GHSA-27r3-jxmh-p828",
"package": "body-parser",
"why": "Vulnerability is in body-parser's handling of trailing bytes after compressed request body; hal-engine does not expose a compressed-body endpoint, so this path is not reachable in any supported deployment.",
"expires": "2027-04-24",
"acknowledged_by": "hal-engine maintainers"
}
]
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ package, not for somebody reading this repository's commit log.

## [Unreleased]

### Added

- **Store a title for a conversation.** `SessionStore` gains an optional `setTitle(sessionId, title)`, implemented by `InMemorySessionStore` and `MongoSessionStore`; a store implementing only the original members still satisfies the interface. Call `setTitle` to write a consumer-authored label; `listFor` answers it in every row that holds one, with no option to ask. On MongoDB the title is read from the same projection `listFor` already fetches — no second query. A title lives in the same document as the conversation and is erased with it by every existing erasure method.

## [0.11.0] - 2026-10-23

**Upgrading from 0.10.x.** Nothing breaks, but one default changes what a client sees: the engine now refuses an authenticated user's sixth message in a burst, and more than twenty a minute, with `TOO_MANY_REQUESTS` and a `retryAfterMs`. Set `transport.rateLimit: false` if your gateway already limits, or raise the numbers. Everything else is additive: `onFailover`, `countFor`, and the optional `retryAfterMs` field on the error frame.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ interface ChatSession {
createdAt?: string;
/** What `auth.ws` returned for the newest open socket: connection-scoped, never persisted, and re-derived on every upgrade. */
user?: AuthenticatedUser;
/** A label the consumer wrote about this conversation; stored by `setTitle`, never derived. */
title?: string;
}
```

Expand Down
20 changes: 11 additions & 9 deletions specs/hal-engine-architecture/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

## The Big Picture

The system has three layers: a frontend client, a Node.js backend (hal-engine), and a pluggable AI provider. They communicate over WebSocket for real-time streaming.

Check warning on line 12 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "They communicate over WebSocket for real-time streaming."

Check warning on line 12 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "The system has three layers: a frontend client, a Node.js ba…"

Only the middle layer is this repository. The `Client` group below, the `applyUpsert`/`applyDelta`/`applyCommit` functions in Entry Streaming Protocol, the reconnection and unmount behaviour under Connection Lifecycle, and the spinner logic in Tool Execution Loop all describe a browser client. They are here because the protocol only makes sense as a pair, not because any of it ships from here.

Check warning on line 14 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "They are here because the protocol only makes sense as a pai…"

Check warning on line 14 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "The `Client` group below, the `applyUpsert`/`applyDelta`/`ap…"

Check warning on line 14 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "Only the middle layer is this repository."

```mermaid
graph LR
Expand Down Expand Up @@ -75,9 +75,9 @@

### SessionStore

Controls where session data is stored: the default `InMemorySessionStore` keeps sessions in a `Map` ([validated by: creates and retrieves a session](../../src/infrastructure/stores/inMemorySessionStore.test.ts#L13)). Implement this interface to persist sessions in Redis, a database, or any other backing store. The store is generic -- pass your own session type extending `BaseSession`.

Check warning on line 78 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "The store is generic -- pass your own session type extending…"

Check warning on line 78 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "Implement this interface to persist sessions in Redis, a dat…"

<!-- doc-block: src/types/sessionStore.ts#SessionStore -->

Check warning on line 80 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "<!-- doc-block: src/types/sessionStore.ts#SessionStore -->"
```typescript
interface SessionStore<T extends BaseSession = ChatSession> {
create(sessionId: string, userId: string | number, options?: SessionCreateOptions): Awaitable<T>;
Expand All @@ -97,6 +97,8 @@
lookup?(sessionId: string): Awaitable<SessionLookup<T>>;
/** How many conversations this user owns; personal data scoped to one user — never the store-wide total (specs/hal-engine-conversation-list). */
countFor?(userId: string | number): Awaitable<number>;
/** Stores a consumer-written title for the conversation; a missing session is silently ignored, and no document is created (specs/hal-engine-conversation-list/spec.md). */
setTitle?(sessionId: string, title: string): Awaitable<void>;
}
```

Expand All @@ -118,7 +120,7 @@

### WsAuthenticator

Authenticates WebSocket connections during the handshake. It receives the whole Node `IncomingMessage`, not a pre-extracted token, so it can read credentials from wherever the client put them. Return the authenticated user to accept the connection, or `null` to reject it with HTTP 401 -- a rejection is a returned `null`, not a thrown error.

Check warning on line 123 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "It receives the whole Node `IncomingMessage`, not a pre-extr…"

Check warning on line 123 in specs/hal-engine-architecture/spec.md

View workflow job for this annotation

GitHub Actions / Verify

Testable statement in a in-progress spec/ADR has no test link — add ([validated by](path/to/test.ts#Lline)) to the test that validates it, or move it under a narrative heading (Background / Rationale / Open Questions …) if it states no testable behaviour. Statement: "Authenticates WebSocket connections during the handshake."

<!-- doc-block: src/types/auth.ts#WsAuthenticator -->
```typescript
Expand Down Expand Up @@ -496,14 +498,14 @@

**The default error handler.** `createApp` always registers one last, after any `errorHandler` a consumer supplies, so nothing a route throws reaches Express's HTML page, and no response body carries an error's message or stack ([validated by: answers a truncated JSON body to POST /hal/chats with 400 {"error":"Bad Request"} as JSON, with no HTML or stack](../../src/transport/httpErrorHandler.test.ts#L48), [validated by: answers a synchronous throw and a rejected promise of boom-secret-42 with 500 {"error":"Internal Server Error"}, keeping the message out of the response](../../src/transport/httpErrorHandler.test.ts#L62)). It answers by the first row that applies:

| The error | Status | Body `error` |
| -------------------------------------------------------------------- | ----------- | ------------------------------------------------- |
| carries `status` or `statusCode` in 400–599 with a standard phrase | that status | Node's phrase from `http.STATUS_CODES`, e.g. `Not Found` |
| an `AIError` coded `RATE_LIMITED` | 429 | `AI service is busy, please try again` |
| an `AIError` coded `SERVICE_UNAVAILABLE` | 503 | `AI service temporarily unavailable` |
| an `AIError` coded `TIMEOUT` | 504 | `AI service timed out` |
| an `AIError` with any other code | 502 | `AI service temporarily unavailable` |
| anything else | 500 | `Internal Server Error` |
| The error | Status | Body `error` |
| ------------------------------------------------------------------ | ----------- | -------------------------------------------------------- |
| carries `status` or `statusCode` in 400–599 with a standard phrase | that status | Node's phrase from `http.STATUS_CODES`, e.g. `Not Found` |
| an `AIError` coded `RATE_LIMITED` | 429 | `AI service is busy, please try again` |
| an `AIError` coded `SERVICE_UNAVAILABLE` | 503 | `AI service temporarily unavailable` |
| an `AIError` coded `TIMEOUT` | 504 | `AI service timed out` |
| an `AIError` with any other code | 502 | `AI service temporarily unavailable` |
| anything else | 500 | `Internal Server Error` |

- The four `AIError` rows are read from `src/transport/errorMapping.ts`, the one table the WebSocket error frame also reads, so a status here and a `code` there never disagree ([validated by: is the only non-test file under src/transport that maps a provider error code](../../src/transport/errorMapping.test.ts#L68)).
- An explicit status wins over the `AIError` code, and one outside 400–599 or without a standard phrase, such as 302, 700 or 499, answers 500 ([validated by: honours status 404 and statusCode 403 with their standard phrases, and answers status 302, 700 and 499 with 500](../../src/transport/httpErrorHandler.test.ts#L100), [validated by: answers AIError codes RATE_LIMITED 429, SERVICE_UNAVAILABLE 503, TIMEOUT 504 and every other code 502, each with its fixed sentence](../../src/transport/httpErrorHandler.test.ts#L76)).
Expand Down Expand Up @@ -575,7 +577,7 @@
- `HalEngineConfig.onFailover` reaches the chain through `composeProvider`, labelled with each provider's config `type`, so a deployment records a durable failover entry from its own configuration ([validated by: hands config.onFailover to the chain, labelled by each provider's config type: failed mock, next vertex on SERVICE_UNAVAILABLE](../../src/config.test.ts#L425)).
- A sustained Vertex `503`, retried to exhaustion, reaches the fallback: the Vertex classifier maps it to a retryable `SERVICE_UNAVAILABLE`, so the retry tier retries it and the fallback tier routes it ([validated by: routes a sustained Vertex 503, retried to exhaustion, to the fallback provider, whose answer the caller receives](../../src/vertexToolLoop.test.ts#L112)).

**GDPR, transfer volume.** A failover re-sends the entire conversation, every message verbatim, to a *second* vendor. A deployer configuring `fallbackProviders` has a second processor to account for, possibly in a different jurisdiction from the first, and the conversation is sent in full to each vendor tried.
**GDPR, transfer volume.** A failover re-sends the entire conversation, every message verbatim, to a _second_ vendor. A deployer configuring `fallbackProviders` has a second processor to account for, possibly in a different jurisdiction from the first, and the conversation is sent in full to each vendor tried.

Fail-over is stateless and per request. It keeps no memory of a vendor being down, so every turn tries the primary first; a circuit breaker that skips a known-down vendor is a different feature with its own state.

Expand Down
Loading
Loading