Skip to content

ENG-3697: Bound Live and Get responses, and stop List deleting its own content - #23

Merged
Dithilli merged 6 commits into
mainfrom
david/eng-3697-bound-live-and-get-responses
Aug 12, 2026
Merged

ENG-3697: Bound Live and Get responses, and stop List deleting its own content#23
Dithilli merged 6 commits into
mainfrom
david/eng-3697-bound-live-and-get-responses

Conversation

@Dithilli

@Dithilli Dithilli commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What

simplifyDocument() builds each row's text from summary/highlights, then drops the hyperdoc tree. Only the query path returns ScoredDocumentResponse (schemas.py:152) — the sole model carrying those fields. /memories/list (documents.py:267), /memories/get/* and every /live/* route return the plain DocumentResponse (schemas.py:75): no summary, no highlights, body only in the tree.

That produced two defects pointing in opposite directions.

1. Document → List deletes its own content. Simplify is wired there with the default on, so since 0.7.0 every row comes back with text: "" and the body dropped — metadata-only rows. Live in both 0.7.0 and 0.7.1 right now.

2. Document → Get and all three document-shaped Live operations had no bounding at all. They emit the full tree. The Live resource is the surface built for an AI-agent node (api/live.py:5), which made it simultaneously the most likely to feed a model and the only one shipping an unbounded payload. This is the token issue from the Aug 4 war room.

Operation Before After
search: search / answer bounded, correct unchanged
document: list content deleted body text, capped
document: get unbounded tree bounded
live: search unbounded tree bounded
live: get resource unbounded tree bounded
live: list resources unbounded tree bounded

How

matchedText() falls back to flattenHyperdoc() — a depth-first walk of the tree's text nodes, capped at MAX_SIMPLIFIED_TEXT = 2000 to mirror the API's own per-highlight cap — when summary and highlights are both absent. The toggle is then wired to the four operations that lacked it, including a new simplifyOne postReceive because a get returns a bare object rather than a result array.

Live → List Sources is deliberately excluded: capability descriptors, no tree to bound.

Test changes, and why

Six existing tests failed against this and are updated on purpose:

  • Five called the unwrappers with this === undefined. That was safe only while those functions read no node parameters; they now read Simplify, and n8n always binds a real context. The stub was fixed, and two assertions whose actual subject was the envelope contract now pass Simplify: false explicitly.
  • One asserted text === '' for a document with neither summary nor highlights. That encoded the bug. It now asserts the tree fallback, and a separate new test covers the genuine empty case — no text anywhere in the tree.

Verification

  • npm run build, npm run lint, npm test92/92 pass (was 82)
  • Document → List probe: text: "" → the real body
  • 200 KB live document: 200,155 bytes → under 2,500
  • Simplify: false returns the raw tree untouched on every affected operation

Not verified: none of this has run inside a real n8n instance. The suite exercises the built dist/ directly, same as the pre-existing tests. Whether this resolves what IntentHQ observes is unproven until 0.7.2 runs on their instance.

Follow-ups

  • The List regression is a distinct customer-visible defect that currently exists on no ticket — filing separately.
  • ENG-3697's other half ("is IHQ actually on 0.7") needs instance access and is unaffected by this PR.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…n content

Simplify built each row's `text` from `summary`/`highlights` and dropped the
hyperdoc tree. Only the query path returns `ScoredDocumentResponse`, the sole
model carrying those fields (schemas.py:152). `/memories/list`,
`/memories/get/*` and every `/live/*` route return the plain `DocumentResponse`
(schemas.py:75) — no summary, no highlights, body only in the tree.

Two consequences, in opposite directions:

- `Document → List` had Simplify wired with the default on, so since 0.7.0 it
  has returned every row with `text: ""` and the body dropped. Metadata-only
  rows; the content was deleted outright.
- `Document → Get` and all three document-shaped `Live` operations had no
  bounding at all, so they emitted the full tree. The Live resource is the
  surface built for an AI-agent node, which made it both the most likely to
  feed a model and the only one shipping an unbounded payload.

`matchedText()` now falls back to a depth-first flatten of the tree, capped at
2000 chars to mirror the API's own per-highlight cap, and the toggle is wired
to the four operations that lacked it. `Live → List Sources` is excluded on
purpose: capability descriptors, no tree to bound.

Six existing tests failed against this and are updated deliberately. Five
called the unwrappers with `this === undefined` — safe only while those
functions read no parameters; n8n always binds a real context. The sixth
asserted `text === ''` for a document with no summary or highlights, which
encoded the bug; it now asserts the tree fallback, with a separate test that
text is empty only when the document genuinely has no text anywhere.

Verified: build, lint, 92/92 tests (was 82). A 200KB live document goes from
200,155 bytes to under 2,500. Not yet exercised inside a real n8n instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 12, 2026

Copy link
Copy Markdown

ENG-3697

ENG-3879

@entelligence-ai-pr-reviews

entelligence-ai-pr-reviews Bot commented Aug 12, 2026

Copy link
Copy Markdown

EntelligenceAI PR Summary

This PR bounds document payloads across Live search/get/list and Document get responses, with simplification enabled by default and an opt-out for callers that need the full hyperdoc tree. Simplification now falls back to depth-first hyperdoc flattening when summaries or highlights are absent, preserves structured leaf fields, and caps generated text at 2,000 characters, preventing List and Live responses from losing their content while avoiding unbounded payloads. Live Search, Get, and List now share Connection ID handling that trims values, omits empty inputs, and rejects non-UUIDs before requests reach the API, with clearer guidance for AI Agent usage. Added regression coverage verifies payload bounding, content preservation, single-document handling, cursor/envelope behavior, and connection validation.

flowchart TD
    LiveOps["Live Search / Get / List"] --> Connection["Shared Connection ID property"]
    Connection --> Validate["preSend UUID validation"]
    Validate --> LiveAPI["Live API"]
    LiveAPI --> LiveBound["Bound Live output"]
    DocGet["Document Get"] --> DocBound["simplifyOne postReceive"]
    DocAPI["Document List / Get responses"] --> Simplify["simplifyDocument"]
    Simplify --> Fallback["Flatten hyperdoc tree and cap text"]
    Fallback --> BoundOutput["Simplified bounded item"]
    LiveBound --> Simplify
    DocBound --> Simplify

    classDef newBehavior fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class LiveOps,Connection,Validate,LiveAPI,LiveBound,DocGet,DocBound,DocAPI,Simplify,Fallback,BoundOutput newBehavior;
Loading

🟢 Green = new or changed in this PR


Review Scorecard

Dimension Rating Basis
Code Quality ●●●○○ 3/5 — Needs Work 1 significant finding(s) — reviewer rated the code Needs Work
Blast Radius Low changed symbols are referenced only within their own file(s); no high-impact surface touched, 9 file(s) / ~325 line(s) changed (size only — not a blast signal)
Merge Confidence ●●●●○ 4/5 — Mostly Safe code quality 3/5 × Low blast radius

Issues found:

  • Significant nodes/Hyperspell/resources/simplify.ts — Cap summary and highlight text before emitting it
  • Unresolved nodes/Hyperspell/resources/simplify.ts — Cap summary and highlight text before returning simplified hits (unresolved from an earlier review)
  • Unresolved tests/connection-id.test.mjs — Assert the actual preSend guard (unresolved from an earlier review)

Fix before merge but low risk because the blast radius is contained to the changed document-simplification paths. The main correctness issue is that simplifyDocument in nodes/Hyperspell/resources/simplify.ts returns summaries or joined highlights without applying MAX_SIMPLIFIED_TEXT, so the advertised response bound can still be exceeded; this is the substantive unresolved finding from the earlier review as well. The fallback flattening and preservation of structured leaf fields are otherwise clear improvements that prevent List and Live responses from losing content, while the unresolved connection-id test comment is only a minor assertion-quality nit.

Evaluated against
  • 3/3 changed files reviewed
  • criteria: correctness, security & access control, robustness & error handling, concurrency & data integrity, repo conventions / steering docs
  • steering docs: none found in repo
Files requiring special attention
  • nodes/Hyperspell/resources/simplify.ts
  • tests/connection-id.test.mjs

Comment thread nodes/Hyperspell/resources/simplify.ts
Comment on lines 102 to +112
function matchedText(document: HyperspellDocument): string {
if (typeof document.summary === 'string' && document.summary.length > 0) {
return document.summary;
}
const highlights = Array.isArray(document.highlights) ? document.highlights : [];
return highlights
const fromHighlights = highlights
.map((highlight) => highlight?.text)
.filter((text): text is string => typeof text === 'string' && text.length > 0)
.join('\n\n');
if (fromHighlights.length > 0) return fromHighlights;
return flattenHyperdoc(document.document, MAX_SIMPLIFIED_TEXT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR PAYLOAD_BOUND Cap summary and highlight text before returning simplified hits

Search and Answer still pass through summary verbatim or join every highlight without applying MAX_SIMPLIFIED_TEXT. Their callers in search/index.ts therefore can emit multi-kilobyte simplified rows, so the response bound only works for tree-fallback documents.

Comment thread nodes/Hyperspell/resources/simplify.ts
Comment thread tests/live-output.test.mjs Outdated
The fixture backing the List regression was hand-built from reading
schemas.py, and named PROD_LIST_RESPONSE in a file whose convention reserves
that framing for verbatim recordings — the existing fixtures say so
explicitly, warning that hand-rolled JSON lets "backend schema drift hide
behind an idealized fixture". Mine was exactly that, and the reasoning was
circular: read DocumentResponse, build a DocumentResponse-shaped object,
prove the code mishandles DocumentResponse.

Now RECORDED_LIST_RESPONSE, verbatim `GET /memories/list?size=2` bytes from a
local core (app 1 / hyperdev) on 2026-08-12, captured after adding one
document through POST /memories/add. The recording independently confirms the
premise this whole change rests on — item keys are exactly resource_id,
source, type, title, status, collection, metadata, ingested_at,
last_modified_at, document_date, document, with no summary, no highlights and
no score, and the body only inside the tree.

Adds a fixture guard test so that if core ever puts summary/highlights on the
list path, the suite fails loudly rather than the fallback quietly becoming
dead code. Assertions now read identity fields off the fixture instead of
hardcoding literals.

Two assertions changed with the fixture: the recorded document is `pending`,
not `completed` as the invented one claimed.

93/93 pass, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Dithilli

Copy link
Copy Markdown
Contributor Author

End-to-end verification inside a real n8n instance

Previously this PR was only backed by unit tests against the built dist/. It has now been run through n8n 2.34.5 (IHQ is on 2.31.5), with the node installed as a community node in ~/.n8n/nodes and executed via n8n execute, hitting a real local core API — real HTTP, real credential resolution, real declarative routing, real postReceive chain.

Control vs. fix — same workflow, same API, same document

Document → List, Simplify left at its default:

Node version text length bytes emitted
Published 0.7.1 (installed from npm — what customers run today) '' 0 371
This branch 'The quarterly board review covered three items: pipeline health, the EU cell rollout, and hiring…' 183 554

The published version returns the row with its content deleted. This branch returns the body. Both runs used an identical workflow and the same resource_id.

Emitted row keys on this branch:

['resource_id','source','type','title','text','collection',
 'metadata','ingested_at','last_modified_at','document_date','status']

Tree dropped, envelope preserved, text populated.

Fixture is now recorded, not invented

The fixture backing the List tests was hand-built from reading schemas.py and misleadingly named PROD_LIST_RESPONSE, in a file whose convention reserves that for verbatim recordings. That was circular reasoning — it could only prove the code agreed with my reading of the schema.

Replaced with RECORDED_LIST_RESPONSE: verbatim GET /memories/list?size=2 bytes from a local core. The recording independently confirms the premise — item keys carry no summary, no highlights, no score, and the body exists only inside the document tree. A guard test now fails loudly if core ever adds those fields to the list path.

93/93 tests pass, lint clean.

Still not covered

  • The Live operations were not exercised end-to-end. They need a connected third-party source, which a local stack has no credentials for. The Live bounding is covered by unit tests against the recorded HubSpot envelope only — and Live is the surface that motivated this PR, so that gap matters.
  • Not tested on n8n 2.31.5 specifically, the version IHQ runs.
  • The 2000-char cap on Live → Get Resource remains a judgment call, not a measurement.

Incidental finding, unrelated to this PR

GET /memories/get/{source}/{id} and GET /memories/list returned different document trees for the same resource at the same momentget gave text: "" while list gave the full body, both with status: pending, and the hyperdoc id differed on every call. Possibly correct-by-design for pending documents, possibly not. Flagging rather than chasing; not caused by and not addressed in this PR.

…ping a 502

Reported from IHQ's instance on n8n 2.33.4 Cloud: Live → Search failing with
`502 "Upstream source error."`, raised by our own raiseApiErrors.

That message is wrong in a way that costs the workflow author real time — it
blames the provider, when no provider was ever contacted. Prod logs show the
actual cause:

  Live source call failed: DBAPIError(asyncpg.exceptions.DataError:
  invalid input for query argument $4: 'linear'
  (invalid UUID 'linear': length must be between 32..36 characters, got 6))

`connection_id` is arriving as a source name. The node sets usableAsTool, so
an AI Agent fills the parameters from their descriptions, and the old wording
— "Specific connection ID when the user has multiple connections for this
source" — reads to a model as an invitation to name the source. Prod saw
exactly that: 'linear', 'github', 'google_drive'. Core then puts the string
into a UUID column (live_access.py `stmt.where(Connection.id ==
connection_id)`, parameter $4), asyncpg rejects it, and a blanket
`except Exception` in api/live.py maps it to the generic 502.

Reproduced against a local core, confirming it is the parameter and not the
source being unavailable:

  POST /live/linear/search {"query":"test"}
    -> 400 "No linear connection is accessible to this user."   (clear)
  POST /live/linear/search {"query":"test","connection_id":"linear"}
    -> 502 "Upstream source error."                             (opaque)

Core should return a 400 here, and that is tracked separately — but the node
should not send a malformed request in the first place, and a guard here keeps
working against every already-deployed core version.

All three document-shaped Live operations shared this field by copy-paste, on
two different transports (Search posts a body; Get and List Resources use the
query string). They now share one definition:

  * preSend rejects a non-UUID with a message naming the likely mistake.
    Deliberately not a silent drop — silently ignoring it would make a request
    scoped to the wrong connection look like it succeeded.
  * The description states the format and the default before the purpose,
    since that text is the model's only input when running as a tool.

Verified end-to-end in n8n 2.34.5 against a local core. The same workflow that
produced `502 Upstream source error.` now fails in the node with:

  Connection ID must be a connection UUID, not "linear".

99/99 tests pass (was 93), lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Dithilli

Copy link
Copy Markdown
Contributor Author

Added: the Live 502 reported from IHQ's instance

Scope grew by one commit (81d0303) — this is the 502 "Upstream source error." on Live → Search, reported from n8n 2.33.4 Cloud.

It is not a provider outage, and not user error. Prod logs give the real cause, which the 502 hides:

Live source call failed: DBAPIError(asyncpg.exceptions.DataError:
  invalid input for query argument $4: 'linear'
  (invalid UUID 'linear': length must be between 32..36 characters, got 6))

connection_id is arriving as a source name. This node sets usableAsTool, so when it runs as an AI Agent tool the model fills parameters from their descriptions — and the old wording, "Specific connection ID when the user has multiple connections for this source", reads to a model as an invitation to name the source. Prod saw exactly that: linear, github, google_drive.

Core then puts the string into a UUID column (live_access.pystmt.where(Connection.id == connection_id), which is parameter $4), asyncpg rejects it, and the blanket except Exception in api/live.py maps it to the generic 502 — a message that blames a provider that was never contacted.

Reproduced against a local core

Confirms it is the parameter, not the source being unavailable:

Request Result
{"query":"test"} 400"No linear connection is accessible to this user." — clear
{"query":"test","connection_id":"linear"} 502"Upstream source error." — opaque

The change

All three document-shaped Live operations had this field by copy-paste, across two transports (Search posts a body; Get and List Resources use the query string). They now share one definition with:

  • a preSend guard rejecting a non-UUID, naming the likely mistake. Deliberately not a silent drop — silently ignoring it would make a request scoped to the wrong connection look like it succeeded, which is the exact silent-wrong-answer class this node keeps getting bitten by.
  • a rewritten description that states the format and the default before the purpose, since that text is the model's only input when running as a tool.

Verified end-to-end

n8n 2.34.5 against a local core. The same workflow that produced 502 Upstream source error. now fails in the node with:

Connection ID must be a connection UUID, not "linear".

Connection ID identifies ONE specific connection when a user has connected the same source more than once — it is not the source name. Leave it empty to use the caller's connection automatically. If this node is running as an AI Agent tool, pin this field to empty so the model cannot fill it.

99/99 tests pass (was 93), lint clean.

Needs a companion fix in core — not in this PR

The API should return 400 for a malformed connection_id, not 502. Two reasons this PR isn't sufficient on its own:

  1. Any other client — the SDKs, a customer's own agent, raw HTTP — still gets the misleading 502.
  2. More broadly, api/live.py's blanket except Exception → 502 "Upstream source error." will keep converting our own bugs into apparent provider outages. A DataError on our query is not an upstream failure.

Different repo, so it needs its own PR.

Comment thread nodes/Hyperspell/resources/live/connectionId.ts Outdated
Comment thread nodes/Hyperspell/resources/live/connectionId.ts
for (const prop of [body, query]) {
assert.equal(prop.name, 'connection_id');
assert.equal(prop.routing.send.property, 'connection_id');
assert.equal(prop.routing.send.preSend.length, 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT TEST_ASSERTION Assert the actual preSend guard

This only checks that one preSend entry exists, not that it is validateConnectionId. The direct validation tests can pass while connectionIdProperty wires an unrelated no-op guard, allowing malformed IDs through the shared property.

Suggested change
assert.equal(prop.routing.send.preSend.length, 1);
assert.deepEqual(prop.routing.send.preSend, [validateConnectionId]);
Prompt to fix with AI

Copy this prompt into your AI coding assistant to fix this issue.

In tests/connection-id.test.mjs:86, replace the length-only assertion with an assertion that `prop.routing.send.preSend` exactly equals `[validateConnectionId]`, so the shared property test fails if the validator is replaced or omitted.

Dithilli and others added 3 commits August 12, 2026 13:21
… column

The preSend guard trims before validating, so "   " is accepted as "not
provided" — but the routing expression did not trim, and "   " is truthy
in JS. The node therefore shipped it, core put it in the UUID-typed
Connection.id column, asyncpg raised DataError, and the caller got back
`502 "Upstream source error."`: the precise failure this field exists to
prevent, still reachable through the guard meant to stop it.

Reproduced end to end against a pre-fix core, then confirmed fixed.

Trimming on send also rescues a pasted " <uuid> ". Core parses the value
with UUID(), which does not tolerate surrounding whitespace, so the
padded form 400s there — and 502s on any core without that validation.

Tests evaluate the routing expression the way n8n does, asserting what
actually goes on the wire rather than the shape of the template, and pin
the guard and the expression together so neither regresses alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The field is type:'string', but an expression resolves to whatever it
evaluates to — `={{ $json.id }}` over a numeric column yields a number.
The guard's `typeof raw === 'string'` check turned any non-string into
'', which returned early as "not provided" while the routing expression
still put the raw truthy value on the wire: the same bypass the trim fix
just closed, reached by a different route.

Non-strings now raise the same named error a source name does.

The wire expression coerces with String() rather than calling .trim()
directly, because routing values are evaluated BEFORE preSend — a bare
.trim() on a number throws a raw TypeError from inside the expression and
pre-empts the guard's readable message. Verified in a real n8n instance:
an expression yielding 123 now reports "Connection ID must be a
connection UUID, not "123"." instead of a TypeError.

Both findings were also raised independently by Entelligence on #23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… one

Two defects in flattenHyperdoc, both reachable from Live → List Resources
the moment Simplify defaulted on for it in this branch.

Content loss. Not every hyperdoc node carries `text`. A HubSpot contact
arrives as {type:'person', name, email, company, children: []} — the
shape recorded from prod in the 2026-06-11 incident and already sitting
in this repo's own fixture. Walking only text/children flattened it to
'', and simplifyDocument then dropped the tree, so the email and company
were deleted outright. That is precisely the defect this file fixes for
Document → List, reintroduced on a different resource shape; the test
asserting it called the empty result "correct".

Leaf nodes now contribute their named scalar fields, labelled, so a model
reads "email: emailmaria@hubspot.com" rather than nothing. Only leaves: a
node with children holds its content there, and folding a document root's
own `title` into the text would duplicate a field the simplified row
already carries — the duplication this file's allow-list exists to stop.
The existing suite caught that, which is why the rule is narrow.

Cap. `remaining` counted node text but not the '\n' join, so a tree of
many small nodes returned close to 2x MAX_SIMPLIFIED_TEXT in separators
alone. Separators are now charged to the same budget. Measured end to end
against a real 266KB document: 2002 chars before, exactly 2000 now.

Both raised independently by Entelligence on #23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines 151 to +161
function matchedText(document: HyperspellDocument): string {
if (typeof document.summary === 'string' && document.summary.length > 0) {
return document.summary;
}
const highlights = Array.isArray(document.highlights) ? document.highlights : [];
return highlights
const fromHighlights = highlights
.map((highlight) => highlight?.text)
.filter((text): text is string => typeof text === 'string' && text.length > 0)
.join('\n\n');
if (fromHighlights.length > 0) return fromHighlights;
return flattenHyperdoc(document.document, MAX_SIMPLIFIED_TEXT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR PAYLOAD_BOUND Cap summary and highlight text before emitting it

simplifyDocument returns summary or joined highlights without applying MAX_SIMPLIFIED_TEXT. Search's postReceive uses this helper for every query/answer hit, so a response with multiple 2,000-character highlights can still emit an unbounded row despite Simplify being advertised as the payload bound.

Prompt to fix with AI

Copy this prompt into your AI coding assistant to fix this issue.

Ensure matchedText applies MAX_SIMPLIFIED_TEXT to summary and concatenated highlights as well as the hyperdoc fallback. Add a regression test through simplifyDocuments using multiple long highlights or a long summary and assert the emitted text length is at most MAX_SIMPLIFIED_TEXT.

@Dithilli
Dithilli merged commit 2c4ce5f into main Aug 12, 2026
2 checks passed
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