Skip to content

feat(agent-bff): expose collection list and count with flat mapping#1742

Open
nbouliol wants to merge 18 commits into
mainfrom
feature/prd-671-expose-collection-list-and-count-with-flat-mapping
Open

feat(agent-bff): expose collection list and count with flat mapping#1742
nbouliol wants to merge 18 commits into
mainfrom
feature/prd-671-expose-collection-list-and-count-with-flat-mapping

Conversation

@nbouliol

@nbouliol nbouliol commented Jul 7, 2026

Copy link
Copy Markdown
Member

What

Slice 3 of the Forest BFF: expose POST /v1/:collection/list and /count as flat REST over the agent.

  • list{ data: [{ ...directFields, id, __forest: { collection, primaryKey } }], meta: { countStatus: "not_requested" } }. primaryKey is the opaque packed id unpacked into a typed { field: value } map using the read-model's key metadata (composite ids supported; a composite id without key metadata is surfaced as a mapping error, never guessed).
  • count{ count, countStatus }. Deactivation is derived from the raw agent payload (meta.count === "deactivated"), not the lossy Number() helper. Active empty collection → 0/available; no usable signal → mapping_error (never a silent 0).
  • Nested-relation guard (PRD-669) wired on list projection/filter/sort and count filter422 relation_field_not_supported.
  • Collection resolution via the read-model allow-list.
  • Errors mapped through PRD-670 (mapAgentError for agent failures; local envelope for BFF-origin errors, incl. a new 503 schema_unavailable).
  • All agent calls go through the exported HttpRequester, so the BFF controls the query params — notably the resolved timezone (spec: never lean on agent-client's Europe/Paris default).

Stacking / dependencies

This work depends on two PRs still in review, whose branches are merged into this one. Until they land on main, this PR's diff temporarily includes their code:

PRD-669 (#1736) is already merged to main; its content here is identical. Rebase onto main once #1732 and #1738 land, then this diff narrows to the data/ additions + read-model/cli-core/bff-local-errors changes.

Open item — 403 not satisfiable in Slice 3

The read-model exposes a single allow-list (the exposed collections), so it cannot distinguish an unknown collection from a known-but-disallowed one. Every absent name maps to 404 unknown_collection; collection_not_allowed (403) has no local trigger. Documented with a TODO in data-routes-middleware.ts and escalated on the ticket.

Tests

Focused tests with a stubbed agent client + read-model: flat mapping + __forest + meta.countStatus; packed/composite id roundtrip; count deactivated vs active-zero vs mapping-error fallback; guard 422 across all four surfaces; 404 resolution; 503 schema_unavailable; agent failure via the error contract; timezone propagated to the agent query. Full package: build + lint + 499 tests green.

Fixes PRD-671

🤖 Generated with Claude Code

Note

Expose collection list and count endpoints in agent-bff with flat mapping

  • Adds POST /agent/v1/:collection/list and POST /agent/v1/:collection/count routes in data-routes-middleware.ts, proxying requests to the configured agent with validation and response shaping.
  • Introduces a ReadModel built from the Forest schema that exposes allowed collections, relations, actions, and primary keys; cached via SchemaCache and CapabilitiesCache with TTL and single-flight refresh.
  • Adds mapListResponse and mapCountResponse in response-mappers.ts to produce flat records with __forest metadata (including structured primaryKey) and deactivation-aware count status.
  • Adds assertNoRelationFieldPaths guard that rejects nested relation field paths (containing :) with a 422 error across projection, filter, and sort inputs.
  • Wires real data routes into cli-core.ts, falling back to a stub middleware with a warning when agent URL or API key config is missing.
  • Risk: pagination now enforces that offset must be a multiple of limit; requests with arbitrary offsets are rejected with 400.

Macroscope summarized e86119b.

nbouliol and others added 15 commits July 3, 2026 17:00
… and count

Add a pure syntactic guard that rejects any top-level field path carrying
the relation separator ":" with 422 relation_field_not_supported. Closes an
agent authority gap where a relation-target field could be projected without
the target collection browse/scope check. Guard + tests only; wiring into
live list/count handlers belongs to the data-endpoints slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GqCtMftj4gwCo2AwicMNL
…-list

Add a read-model layer that caches the agent schema (24h), derives
collection/relation/action allow-list metadata with relation targets and
an action-endpoint map, and caches per-collection capabilities coupled to
the schema refresh. Adds a Metrics port emitting schema-cache and
action-endpoint counters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…argets

A reference is `${foreignCollection}.${key}`; the collection name may itself
contain dots (mongoose nested collections like `User.address`). Split on the
last dot only instead of taking the first segment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… store

Add tests for createReadModel wiring, ForestSchemaClient (mocked
SchemaService), ReadModelStore.ageSeconds delegation, and the read-model
defensive branches (no-reference relation, actions-less collection, unknown
collection). read-model files at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… mutation

Address review feedback:
- guard `collection.fields ?? []` so a malformed collection cannot wedge the
  store for the 24h TTL
- treat an empty schema as a failed fetch (cold -> SchemaUnavailableError,
  warm -> serve last good) instead of caching an all-denying schema
- detect a schema refresh via an explicit revision counter instead of array
  reference identity
- defensively copy action fields/hooks so a consumer cannot corrupt the
  shared cached schema
- strengthen the console-metrics fallback test to assert routing, not presence

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cache

Deep-freeze the action-endpoint map and relation targets at construction (after
the field/hook clone that detaches them from the source schema), so the getters
can return live references safely. Add a note on the getCapabilities TOCTOU to
resolve when the data endpoints wire it in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add mapAgentError() translating agent-client AgentHttpError failures into
the BFF error envelope, plus the complete registry of BFF-local error
factories the Slice-3 endpoints emit.

Fixes PRD-670

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- unmapped agent error name now returns 500 mapping_error instead of a
  silent status fallback
- extract the unmapped-name warn out of fallbackTypeByStatus (single
  responsibility)
- use a named constant for the validation_error type
- make the name-to-type test use an explicit table instead of deriving
  expectations from the map under test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mapping

- fall back to the outer AgentHttpError status when the JSON:API error
  object omits its own status, instead of hardcoding 400
- fall back to errors[0].message when detail is absent, before the
  generic default message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dies

When the agent body is not a JSON error object, use AgentHttpError.responseText
as the message source before the generic default, so plain-text or empty
bodies still surface the agent-supplied text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSON:API expresses errors[0].status as a string; a string status reached
BffHttpError and was rejected by isSerializableError, downgrading a mapped
4xx to a generic 500. Coerce it to a number before constructing the error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lities-and-allow-list' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
…op-level-list-and' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
…act' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
Add POST /v1/:collection/list and /count as flat REST over the agent:

- list returns flat records with __forest { collection, primaryKey } and
  meta.countStatus "not_requested"; primaryKey is the opaque id unpacked to a
  typed { field: value } map via read-model key metadata.
- count returns { count, countStatus }, deriving "deactivated" from the raw
  agent payload (meta.count) instead of the lossy Number() helper; an active
  empty collection is 0/available, a missing signal is a mapping_error.
- Wire the nested-relation guard on list projection/filter/sort and count
  filter (422 relation_field_not_supported).
- Resolve the collection via the read-model allow-list. Absent name maps to
  404 unknown_collection; 403 collection_not_allowed has no local trigger yet
  (single allow-list) — see TODO and the ticket escalation.
- All agent calls go through the exported HttpRequester so the BFF controls
  the query params (notably the resolved timezone) for both list and count.
- Add schema_unavailable (503) local error and expose ReadModel.getPrimaryKeys.

Fixes PRD-671

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

PRD-671

@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 5): mapAgentError 3

@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.02%.

Modified Files with Diff Coverage (20)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-http-error.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/validation/relation-field-guard.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/create-read-model.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/forest-schema-client.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/read-model.ts100.0%
New Coverage rating: A
packages/agent-bff/src/http/agent-error-mapper.ts98.3%97
New Coverage rating: F
packages/agent-bff/src/data/agent-data-client.ts33.3%22-28
New Coverage rating: A
packages/agent-bff/src/read-model/schema-cache.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/action-endpoint-resolver.ts100.0%
New Coverage rating: A
packages/agent-bff/src/data/pack-id.ts100.0%
New Coverage rating: A
packages/agent-bff/src/data/response-mappers.ts100.0%
New Coverage rating: A
packages/agent-bff/src/data/agent-query.ts97.2%71
New Coverage rating: A
packages/agent-bff/src/read-model/capabilities-cache.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/read-model-store.ts100.0%
New Coverage rating: A
packages/agent-bff/src/read-model/errors.ts100.0%
New Coverage rating: A
packages/agent-bff/src/adapters/console-metrics.ts100.0%
New Coverage rating: A
packages/agent-bff/src/data/data-routes-middleware.ts90.7%34, 48-50, 97
New Coverage rating: A
packages/agent-bff/src/http/bff-local-errors.ts100.0%
Total97.4%
🤖 Increase coverage with AI coding...
In the `feature/prd-671-expose-collection-list-and-count-with-flat-mapping` branch, add test coverage for this new code:

- `packages/agent-bff/src/data/agent-data-client.ts` -- Line 22-28
- `packages/agent-bff/src/data/agent-query.ts` -- Line 71
- `packages/agent-bff/src/data/data-routes-middleware.ts` -- Lines 34, 48-50, and 97
- `packages/agent-bff/src/http/agent-error-mapper.ts` -- Line 97

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Comment on lines +85 to +95
if (agentError.name !== undefined) {
const type = AGENT_ERROR_TYPE_MAP[agentError.name];

if (type === undefined) {
logger('Error', 'Unmapped agent error name', { name: agentError.name, status });

return mappingError(`Unmapped agent error name: ${agentError.name}`);
}

return new BffHttpError(status, type, message, agentError.data);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium http/agent-error-mapper.ts:85

When a JSON:API error from the agent has a name not present in AGENT_ERROR_TYPE_MAP, mapJsonApiError returns a 500 mapping_error instead of preserving the agent's original status and type. For example, a custom agent error subclass named CustomNotFoundError that carries status: 404 gets remapped to a 500 internal BFF failure, turning a legitimate 4xx client error into a server error.

This happens in the branch where agentError.name !== undefined and AGENT_ERROR_TYPE_MAP[agentError.name] returns undefined. Consider falling back to fallbackTypeByStatus(status) (the same path used when name is absent) rather than calling mappingError(), so unmapped error names still preserve their original HTTP status and a sensible error type.

  if (agentError.name !== undefined) {
-    const type = AGENT_ERROR_TYPE_MAP[agentError.name];
-
-    if (type === undefined) {
-      logger('Error', 'Unmapped agent error name', { name: agentError.name, status });
-
-      return mappingError(`Unmapped agent error name: ${agentError.name}`);
-    }
-
-    return new BffHttpError(status, type, message, agentError.data);
+    const type = AGENT_ERROR_TYPE_MAP[agentError.name] ?? fallbackTypeByStatus(status);
+
+    return new BffHttpError(status, type, message, agentError.data);
Also found in 1 other location(s)

packages/agent-bff/src/read-model/action-endpoint-resolver.ts:39

resolve() swallows SchemaUnavailableError from getReadModel() and returns undefined as if the action mapping were simply missing. On a cold schema cache while Forest is unavailable, callers cannot distinguish "schema unavailable" from "unknown action" and will mis-handle the request (typically turning a transient 503 condition into a false miss/404).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/http/agent-error-mapper.ts around lines 85-95:

When a JSON:API error from the agent has a `name` not present in `AGENT_ERROR_TYPE_MAP`, `mapJsonApiError` returns a 500 `mapping_error` instead of preserving the agent's original status and type. For example, a custom agent error subclass named `CustomNotFoundError` that carries `status: 404` gets remapped to a 500 internal BFF failure, turning a legitimate 4xx client error into a server error.

This happens in the branch where `agentError.name !== undefined` and `AGENT_ERROR_TYPE_MAP[agentError.name]` returns `undefined`. Consider falling back to `fallbackTypeByStatus(status)` (the same path used when `name` is absent) rather than calling `mappingError()`, so unmapped error names still preserve their original HTTP status and a sensible error type.

Also found in 1 other location(s):
- packages/agent-bff/src/read-model/action-endpoint-resolver.ts:39 -- `resolve()` swallows `SchemaUnavailableError` from `getReadModel()` and returns `undefined` as if the action mapping were simply missing. On a cold schema cache while Forest is unavailable, callers cannot distinguish "schema unavailable" from "unknown action" and will mis-handle the request (typically turning a transient `503` condition into a false miss/`404`).

if (field.reference) {
// reference is `${foreignCollection}.${key}`; the collection name may itself contain dots
// (e.g. mongoose nested collections like `User.address`), so drop only the trailing key.
return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium read-model/read-model.ts:24

toRelationTarget returns an empty string as the target collection for relations whose field.reference is a bare collection name (e.g. 'orders') with no trailing .<key> segment. split('.').slice(0, -1).join('.') strips the only token, so getRelationTarget() records target: '' for a valid HasMany/BelongsToMany relation, and callers that rely on the resolved target mis-handle or reject the relation. The code assumes every reference is ${foreignCollection}.${key}, but schemas also use bare collection names. Consider falling back to the full field.reference when no . is present.

-    return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') };
+    const target = field.reference.includes('.')
+      ? field.reference.split('.').slice(0, -1).join('.')
+      : field.reference;
+    return { type, polymorphic: false, target };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/read-model/read-model.ts around line 24:

`toRelationTarget` returns an empty string as the target collection for relations whose `field.reference` is a bare collection name (e.g. `'orders'`) with no trailing `.<key>` segment. `split('.').slice(0, -1).join('.')` strips the only token, so `getRelationTarget()` records `target: ''` for a valid `HasMany`/`BelongsToMany` relation, and callers that rely on the resolved target mis-handle or reject the relation. The code assumes every `reference` is `${foreignCollection}.${key}`, but schemas also use bare collection names. Consider falling back to the full `field.reference` when no `.` is present.

Comment thread packages/agent-bff/src/data/pack-id.ts
createPerKeyOriginMiddleware(),
createTimezoneMiddleware({ defaultTimezone }),
createAgentStubMiddleware(),
buildDataRoutesMiddleware(config, logger),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High src/cli-core.ts:191

In OAuth mode, requests to /agent/v1/:collection/list|count fail because buildDataRoutesMiddleware is mounted for every authenticated /agent request but only the API-key branch populates ctx.state.agentToken. When the OAuth path runs, createAuthModeMiddleware sets only ctx.state.principal, leaving ctx.state.agentToken undefined, so the data routes call the agent with Authorization: Bearer undefined. Either pass the OAuth bearer token into ctx.state.agentToken for the data routes or gate buildDataRoutesMiddleware so it only runs in API-key mode.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/cli-core.ts around line 191:

In OAuth mode, requests to `/agent/v1/:collection/list|count` fail because `buildDataRoutesMiddleware` is mounted for every authenticated `/agent` request but only the API-key branch populates `ctx.state.agentToken`. When the OAuth path runs, `createAuthModeMiddleware` sets only `ctx.state.principal`, leaving `ctx.state.agentToken` undefined, so the data routes call the agent with `Authorization: Bearer undefined`. Either pass the OAuth bearer token into `ctx.state.agentToken` for the data routes or gate `buildDataRoutesMiddleware` so it only runs in API-key mode.

- Split dataRoutesMiddleware into handleList/handleCount and a callAgent
  wrapper (removes the duplicated try/catch and the oversized function).
- Drop the unused CountStatus export and the unread ConditionTreeBranch
  aggregator field.
- Name the 'Number' column-type token in pack-id.
- Document why count deactivation is derived payload-only (no schema/
  capabilities count flag exists).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nbouliol

nbouliol commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Addressed review feedback in d885e3b:

  • Function size / duplicationdataRoutesMiddleware split into handleList / handleCount + a callAgent(fn) wrapper (the middleware is now routing + resolution + dispatch; the duplicated try/catch → mapAgentError is gone).
  • Dead CountStatus export — removed (ListResponse/CountResponse keep their inline unions).
  • Dead ConditionTreeBranch.aggregator — removed (only conditions is read).
  • Magic 'Number' — named NUMBER_COLUMN_TYPE constant in pack-id.ts. Did not narrow PrimaryKeyField.type to a literal union: the value is sourced from ForestSchemaField.type: string and the domain ForestServerColumnType is a non-literal composite (array/object forms), so a union would only relocate the risk into an unchecked as cast at construction.
  • Schema-first count-deactivation — confirmed payload-only is the only viable derivation (neither the Forest schema nor capabilities() exposes a count flag; the agent decides countability at request time in access/count.ts). Documented as a comment on mapCountResponse; no code change.

build + lint + 499 tests green.

Comment thread packages/agent-bff/src/data/data-routes-middleware.ts Outdated
nbouliol and others added 2 commits July 8, 2026 15:11
- Trim the pack-id and mapCountResponse comments to one line each.
- Rename RequestHandlerContext to RequestHandlerDeps.
- Drop readModel from the shared deps; pass primaryKeys only to handleList
  (handleCount no longer receives an unused read-model).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fail closed with 401 when no agent token is present (OAuth mode does not
  mint one yet) instead of calling the agent with "Bearer undefined".
- Return 400 invalid_request on a malformed percent-encoded collection name
  instead of a 500 from decodeURIComponent.
- Throw a mapping error when a Number primary-key segment is not numeric,
  mirroring the agent's IdUtils.unpackId, instead of emitting { id: NaN }.
- Pass primaryKeys via a dedicated list deps type (handleList back to 3 params).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nbouliol

nbouliol commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Macroscope review triaged in e86119b.

Fixed (this PR's files):

  • 🟠 OAuth Bearer undefined — data routes now fail closed with 401 unauthorized when ctx.state.agentToken is absent (only the API-key path mints it today), instead of calling the agent with Bearer undefined. Minting an agent token from the OAuth principal is OAuth-foundation work — TODO(PRD-637) added.
  • 🟡 decodeURIComponent → 500 — malformed percent-encoded collection names now return 400 invalid_request (kept in the BFF error envelope, not the ad-hoc body from the suggestion).
  • 🟡 unpackPrimaryKey NaN — a non-numeric segment for a Number key throws a mapping error (mirrors the agent's IdUtils.unpackId) instead of emitting { id: NaN }.
  • qlty handleList params — back to 3 via a dedicated ListHandlerDeps (count still carries no unused fields).

Not changing (dependency-PR files, not owned here):

build + lint + 502 tests green.

timezone: ctx.state.timezone as string,
logger,
};
const body = (ctx.request.body ?? {}) as ListRequestBody & CountRequestBody;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium data/data-routes-middleware.ts:128

A request with projection set to a string (e.g. { "projection": "id" }) or sort set to a string causes the list endpoint to throw a plain TypeError and return a 500 instead of a 400 invalid_request error. The body is cast to ListRequestBody & CountRequestBody without validating that projection and sort are arrays, so non-array values reach buildListAgentQuery() / collectListFieldPaths() and fail when calling array methods like .join/.forEach/.map on a string. Consider validating the shape of body before dispatching to handleList / handleCount so malformed input returns invalidRequest().

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/data/data-routes-middleware.ts around line 128:

A request with `projection` set to a string (e.g. `{ "projection": "id" }`) or `sort` set to a string causes the list endpoint to throw a plain `TypeError` and return a 500 instead of a 400 `invalid_request` error. The body is cast to `ListRequestBody & CountRequestBody` without validating that `projection` and `sort` are arrays, so non-array values reach `buildListAgentQuery()` / `collectListFieldPaths()` and fail when calling array methods like `.join`/`.forEach`/`.map` on a string. Consider validating the shape of `body` before dispatching to `handleList` / `handleCount` so malformed input returns `invalidRequest()`.

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