This page defines the type boundaries from an HTTP list request through QueryShape to a paged result or export. The target envelope is Fields + Paging + Sort. The transport remains QUERY; the Platform SDK falls back to POST /_query when the network path does not support that method.
1. Standard list envelope
A new or migrated HTTP list Query has this shape:
// The HTTP envelope contains serializable query values; trusted tenant and user state stay outside it.public sealed record ListResourcesQuery( ResourceListFields Fields, PaginationParams Paging, SortRequest? Sort = null) : IQuery<Result<PagedResult<ResourceSummaryDto>>>, IPaginatedRequest;
// Business criteria stay in Fields so a new range does not widen the top-level envelope.public sealed record ResourceListFields( string? SearchText = null, DateRange? CreatedAt = null, AmountRange? Amount = null, EnumFilterSet<ResourceStatus>? Statuses = null);| Member | Responsibility | Constraint |
|---|---|---|
Fields | Business filter criteria | Names use domain language; multiple named ranges are valid, but a generic top-level Period is not |
Paging | HTTP offset paging | Uses PaginationParams; IPaginatedRequest is a marker for architecture rules, not data inheritance |
Sort | One standard sort request | SortRequest(Field, Descending); QueryShape must still validate the field against its allowlist |
| Result | List response | Uses Result<PagedResult<TSummaryDto>>; do not introduce a module-specific *Page wrapper |
Tenant, current-user, and authorization scope are not filter fields. The Handler obtains them from trusted context and passes them to the Store or base predicate. JSON content cannot override them.
2. Paging normalization
PaginationParams is one-based and defaults to PageIndex=1, PageSize=20. Normalize() delegates to PagingLimits with the following fixed behavior:
| Input | Normalized value |
|---|---|
PageIndex < 1 | 1 |
PageSize <= 0 | 20 |
PageSize > 1000 | 1000 |
| Any other valid value | Unchanged |
var paging = (request.Paging ?? new PaginationParams()).Normalize();var pageRequest = paging.ToPageRequest();var window = paging.ToWindow();ToWindow() also applies PageWindow deep-paging protection. The default maximum offset is 100_000. Beyond that boundary, CanRead=false; the caller returns an empty page or changes to cursor/keyset paging instead of issuing an overflowing or unbounded database offset.
Platform limits do not all mean the same thing
GlobalPlatformConstants contains stable, cross-module limits that tenants cannot configure:
| Constant | Current value | Scope |
|---|---|---|
Pagination.DefaultPageSize | 20 | Alias for the online-list default in PagingLimits |
Pagination.MaxPageSize | 1000 | Alias for the online-list maximum in PagingLimits |
Batch.PageSize | 2000 | Background reads for SAR, archival, and other workers; not an HTTP page size |
Text.IdentifierMaxLength | 128 | Cross-module stable identifiers |
Text.DescriptionMaxLength | 1000 | Short descriptions |
Text.RemarkMaxLength | 2000 | Remarks and audit reasons |
Remote options at 50, Workflow QueryStore at 200, and SCIM at 200 are named protocol or engine boundaries. They do not change the online-list constants.
3. Filter value objects
DateRange
DateRange represents a closed time range with either side optionally unbounded. Its JSON shape is an object:
{ "from": "2026-08-01T00:00:00+08:00", "to": "2026-08-13T00:00:00+08:00"}Both direct construction and deserialization go through DateRange.Create. A lower bound later than the upper bound returns DateRange.Invalid; the JSON converter turns that failure into JsonException, so deserialization cannot bypass the invariant.
The upper bound has two forms:
| Supplied upper bound | To | Query expansion |
|---|---|---|
Start of a day, such as 2026-08-13T00:00:00+08:00 | Normalized to the last tick of 13 August | QueryToExclusive is the start of 14 August |
Explicit instant, such as 2026-08-13T15:30:45+08:00 | Preserved | The expansion point uses To.AddTicks(1) for an exclusive upper bound |
null | That side is unbounded | No upper-bound predicate |
Contains and display code use the closed To value. Database predicates use an exclusive upper bound with LessThan; they do not use LessThanOrEqual against a normalized end-of-day value.
Amounts, numbers, and enum sets
| Type | JSON/domain meaning | Failure and equality behavior |
|---|---|---|
AmountRange | A decimal? Min/Max closed range with either side optional | Min > Max returns NumberRange.Invalid; both endpoints are included |
NumberRange<TNumber> | A closed range for comparable value types such as quantity, rating, or percentage | Min > Max returns NumberRange.Invalid |
EnumFilterSet<TEnum> | Deduplicates, orders, and freezes its input; exposes only IReadOnlySet<TEnum> | Equality ignores caller ordering and duplicates; null becomes an empty set |
These types belong in the HTTP/Application TFields contract. They express request semantics; they are not generated database fields by themselves.
4. QueryShape scalar expansion boundary
The QueryShape generator continues to consume scalar declarations. *ListInput.From or MapFrom is the single expansion point for composed values:
public sealed partial class ResourceListInput : IDeclareQueryShape{ // BQRY005 requires both paging scalars and keeps them off QueryField. public int PageIndex { get; init; } public int PageSize { get; init; }
[QueryField(DefaultOperator = FilterOperator.GreaterThanOrEqual)] public DateTimeOffset? CreatedAtFrom { get; init; }
[QueryField(DefaultOperator = FilterOperator.LessThan)] public DateTimeOffset? CreatedAtTo { get; init; }
public static ResourceListInput From( ResourceListFields fields, PaginationParams paging) { var normalized = paging.Normalize(); return new ResourceListInput { // Expand the range once; the upper bound becomes an exclusive value for LessThan. CreatedAtFrom = fields.CreatedAt?.From, CreatedAtTo = fields.CreatedAt?.QueryToExclusive ?? fields.CreatedAt?.To?.AddTicks(1), PageIndex = normalized.PageIndex, PageSize = normalized.PageSize, }; }}Generator rule BQRY005 requires PageIndex and PageSize together, typed as int or int?, with neither marked [QueryField]. DateRange, PaginationParams, and other composed filter values also stay off [QueryField]. A generator cannot infer a module’s field, operator, or upper-bound semantics.
The resulting path is:
5. Remote options and related-entity display
QueryOptionItem
All remote selectors return the same display shape:
new QueryOptionItem( Value: "user-42", Label: "Zhang San", // Selected restores UI state; it does not grant access. Description: "Matter owner", Selected: true, // Mapping contains only registered, field-secured display values. Mapping: new Dictionary<string, string?> { ["department"] = "Litigation", });Valueis the stable identifier submitted to the filter field.Selectedrestores recent or existing UI state; it is not an authorization result.Mappingcontains only fields registered by the selector catalog and processed by field-security policy.QueryOptionRequest.MaximumPageSizeis50; bulk identifier resolution also retains no more than 50 identifiers.
RelatedEntitySnapshot
A detail or edit-fill model can carry RelatedEntitySnapshot(Relation, Id, Label, ResourceType, Mapping) so a client does not fetch another detail resource merely to render an associated name. This is not an aggregate relationship. It cannot contain the related aggregate, authorization facts, or unmasked fields. List rows do not carry the snapshot by default; use a QueryShape projection or option Mapping for the minimum associated display fields.
6. Export ranges
Export is not implemented by increasing the online PageSize. The numeric values of ExportScope are already persisted and must not be reordered:
| Name | Value | Required range data |
|---|---|---|
CurrentPage | 0 | Reuses the current list criteria, sort, and page size |
Selected | 1 | Non-empty, unique CheckedIds; at most 1000 |
All | 2 | No CheckedIds, PageFrom/PageTo, or Take |
PageRange | 3 | PageFrom > 0, PageTo > 0, and PageFrom <= PageTo |
Quantity | 4 | Take > 0; export policy owns the maximum, not PagingLimits |
ExportRange expresses these choices as one value. The persisted ExportRequest currently stores Scope, CheckedIds, PageFrom, PageTo, and Take as flat properties. Supplying data for the wrong scope returns ApplicationErrors.Export.InvalidRequest.
// TenantId and RequestedBy come from trusted execution context, not unchecked page fields.var request = new ExportRequest( ExportBuilderKey: "billing-invoices", Format: ExportFormat.Csv, TenantId: tenantId, RequestedBy: userId, Parameters: serializedListFields){ // PageRange requires two positive page numbers; validation rejects a reversed range. Scope = ExportScope.PageRange, PageFrom = 2, PageTo = 4,};The legacy IExportBuilder methods receive only Parameters, so they support All only. A builder that accepts CurrentPage, Selected, PageRange, or Quantity implements IScopedExportBuilder and estimates/reads from the complete validated ExportRequest. Otherwise, the scheduler rejects the request instead of silently widening it to an all-row export.
The public ExportRequestDisplaySnapshot preserves PageFrom, PageTo, and Take, but excludes selected row identifiers, tenant, user, and raw parameters. A business builder can add displayable criteria only through server-side allowlisted fields.
7. Registered stable exceptions
| Surface | Exception | Boundary |
|---|---|---|
| Chat messages | CursorPage<T> cursor paging | Keep the cursor; do not migrate it to PaginationParams |
| Remote selectors | Maximum 50 items | Selector protocol boundary; do not copy it into ordinary lists |
| Workflow QueryStore | Maximum 200 items | Engine guard, not an HTTP-list maximum |
| SCIM lists | Maximum 200 items | Follows the SCIM paging protocol |
| Bounded dictionaries | A small enum, event type set, or catalog may be read once | Move to standard paging beyond 200 items or when interactive filtering appears |
| Operations tenant list | May read across tenants | Trusted operations surface only; the request does not accept an ambient TenantId |
| Single-tenant detail | GetTenantQuery(string TenantId) | A resource identifier on a single-resource GET, not a list environment field |
A new exception records its owner, boundary, and removal condition. A one-time migration status is not a stable exception contract.
8. Migration and verification
When maintaining an existing list:
- Export the current OpenAPI document and determine whether the live request is still flat or already uses
Fields/Paging/Sort. - Move business criteria into
{Resource}ListFields; use domain field names for time ranges and no top-levelPeriod. - Replace flat HTTP paging with
PaginationParamsandIPaginatedRequest. - Expand value objects and normalize paging once in
*ListInput.From/MapFrom. - Keep QueryShape input scalar and use
LessThanfor date upper bounds. - Update the OpenAPI artifact, Platform SDK types, and page calls together.
- Run Domain unit tests, module Query tests, generator tests, and architecture tests.
Direct evidence currently includes:
PaginationParamsTestsfor defaults, the 1000 limit, andPageWindowconversion;DateRangeTestsfor unbounded ranges, reversed-range failure, end-of-day normalization, explicit instants, and JSON validation;RangeValueObjectTestsfor numeric/amount closed ranges and enum-set equality;ExportRangeContractTestsfor range validation, enum values, and snapshot round trips;ListQueryShapeConvergenceArchitectureTests, whose migration scans remainSkipuntil W6.