Skip to content

[WIP] Add query enhancements and advanced validation features - #35

Merged
huangyiirene merged 2 commits into
mainfrom
copilot/add-query-advanced-validation
Jan 20, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
copilot/add-query-advanced-validation

Conversation

Copilot AI commented Jan 20, 2026

Copy link
Copy Markdown
Contributor

Implementation Plan: High Priority Features

📊 Query Enhancements (query.zod.ts)

  • Add aggregation support (GROUP BY, HAVING)
  • Add join types (INNER, LEFT, RIGHT, FULL)
  • Add subquery support
  • Add window function support
  • Add tests for query enhancements

✅ Advanced Validation (validation.zod.ts)

  • Add cross-field validation schema
  • Add async validation schema
  • Add custom validator function schema
  • Add conditional rules schema
  • Add tests for advanced validation

🎨 Theme Configuration (theme.zod.ts - new)

  • Create theme.zod.ts file
  • Add color palette schema
  • Add typography settings schema
  • Add spacing units schema
  • Add border radius and shadows schema
  • Add tests for theme configuration
  • Export from index.ts

📝 Enhanced Field Types (field.zod.ts)

  • Add location field type (GPS coordinates)
  • Add address field type (structured)
  • Add richtext field type (WYSIWYG)
  • Add code field type (syntax highlighting)
  • Add color field type (color picker)
  • Add rating field type (stars)
  • Add signature field type (digital signature)
  • Add helper functions for new field types
  • Add tests for enhanced field types

🔍 Final Validation

  • Run all tests (434 tests pass)
  • Build package to generate JSON schemas
  • Verify no regressions

Summary of Changes

Enhanced Field Types (7 new types)

Added 7 new field types to support richer UI components:

  • location - GPS coordinates with map display support
  • address - Structured address with format options
  • richtext - WYSIWYG editor support
  • code - Syntax highlighting with language selection
  • color - Color picker with format options
  • rating - Star rating with configurable max
  • signature - Digital signature capture

Query Enhancements

Significantly expanded query capabilities for complex analytics:

  • Aggregation: COUNT, SUM, AVG, MIN, MAX, COUNT_DISTINCT, ARRAY_AGG, STRING_AGG
  • Joins: INNER, LEFT, RIGHT, FULL with subquery support
  • Window Functions: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, etc.
  • Group By & Having: Full GROUP BY support with HAVING clause
  • DISTINCT: SELECT DISTINCT support

Advanced Validation

Added 4 new validation types for richer data quality controls:

  • Cross-Field Validation: Validate relationships between fields (e.g., end_date > start_date)
  • Async Validation: Remote validation via API or custom functions
  • Custom Validator: User-defined validation functions
  • Conditional Validation: Rules that apply based on conditions

Theme Configuration (New Module)

Complete theming system for brand customization:

  • Color Palettes: Primary, secondary, semantic colors with variants
  • Typography: Font families, sizes, weights, line heights, letter spacing
  • Spacing: Consistent spacing scale (0-24)
  • Border Radius: Rounded corners (none to full)
  • Shadows: Box shadow effects
  • Breakpoints: Responsive design breakpoints
  • Animation: Duration and timing functions
  • Z-Index: Layering configuration
  • Logo: Light/dark mode logos and favicon
  • Theme Modes: light, dark, auto

Test Coverage

  • Added 28 new tests for query enhancements
  • Added 12 new tests for advanced validation
  • Added 24 new tests for theme configuration
  • Added 14 new tests for enhanced field types
  • Total: 434 tests passing (78 new tests added)
Original prompt

📊 High Priority Features (Do Next)

Query Enhancements

Files: packages/spec/src/data/query.zod.ts
Effort: 3-5 days
Value: Enables complex analytics and reporting

Features:

Aggregation (GROUP BY, HAVING)
Joins (INNER, LEFT, RIGHT)
Subqueries
Window functions
Advanced Validation

Files: packages/spec/src/data/validation.zod.ts
Effort: 2-3 days
Value: Richer data quality controls

Features:

Cross-field validation ("end_date > start_date")
Async validation (remote uniqueness checks)
Custom validator functions
Conditional rules
Theme Configuration

Files: packages/spec/src/ui/theme.zod.ts (new)
Effort: 2-3 days
Value: Brand customization for customers

Features:

Color palettes
Typography settings
Spacing units
Border radius, shadows
Enhanced Field Types

Files: packages/spec/src/data/field.zod.ts
Effort: 2-3 days
Value: More rich UI components

New Types:

location (GPS coordinates)
address (structured)
richtext (WYSIWYG)
code (syntax highlighting)
color (color picker)
rating (stars)
signature (digital signature)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel

vercel Bot commented Jan 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
spec Ready Ready Preview, Comment Jan 20, 2026 5:17am

Request Review

…vements, advanced validation, and theme configuration

Co-authored-by: huangyiirene <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

This PR is very large. Consider breaking it into smaller PRs for easier review.

@huangyiirene
huangyiirene marked this pull request as ready for review January 20, 2026 05:33
@huangyiirene
huangyiirene merged commit 9939947 into main Jan 20, 2026
10 checks passed
Copilot AI removed the request for review from Copilot March 23, 2026 21:51
xuyushun441-sys pushed a commit that referenced this pull request May 25, 2026
Adds entries 26-40 covering the gaps that make the helpdesk template
'pretty but not daily-usable' from an end-user perspective:

P0 additions:
- #26 No inline message composer on detail pages
- #27 No external-user portal mechanism
- #28 Attachment/file-list field UI not E2E

P1 additions:
- #29 No 'changed since last visit' indicator
- #30 Bulk operations UI unverified (escalates #17)
- #31 Rich-text editor scoped to comments only
- #32 No first-class canned response / macro
- #33 No collaboration presence indicators
- #34 No keyboard-shortcut API
- #35 No conditional SLA timer (pause on waiting_customer)
- #36 Formula fields can't reference foreign object fields

P2 additions:
- #37 No chart drill-down
- #38 No period-over-period analytics primitive
- #39 No inbound-channel abstraction (email-to-ticket etc.)
- #40 i18n translation namespace validation weak

Includes 'user-pain → platform-gap' mapping table tracing each end-user
complaint to a specific issue number.

Co-authored-by: Copilot <[email protected]>
os-sam pushed a commit that referenced this pull request Sep 10, 2026
Graded `patch`: nothing is widened and no symbol is added. The contract already
published these shapes; the implementation is coming back to a declaration it had
already published. Checked against the recorded WHICH LEVEL ruling of 2026-09-04
(decision batch #35, on #15294), whose `minor` trigger is additive widening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
os-bill pushed a commit that referenced this pull request Sep 11, 2026
…ged docs page (#15117)

Contract review returned FAIL with two must-fixes.

1. The changeset is `minor`, not `patch`. The written rule
   (`.github/workflows/pr-automation.yml`, maintainer ruling 2026-09-04 batch
   #35) is that a purely additive widening of a published package's public
   surface takes at least `minor`, and a commit type may raise a bump but never
   lower it below what the act requires. The PR's own `Clause-②: yes` line says
   this widens the accept set, in those words. The `find` precedent it leaned on
   does not reach: that was a NARROWING, it landed the day the rule was ruled,
   and the rule disclaims pre-rule `patch` precedents.

   How the wrong level survived local verification is the more useful half:
   `check-changeset-no-major` reads the clause-② declaration from the event
   payload and nothing else, so a local run without `--event` cannot exercise
   the level axis at all. Its exit 0 was recorded as a reading when the
   instrument could not have come back the other way.

2. `content/docs/ui/actions.mdx` — the page the repo's own Docs Drift Check
   flagged on this PR — is re-verified against the rewritten example. Its
   handler snippet still annotated `ctx: ActionContext`, a type that file no
   longer declares; it now imports and annotates the published
   `ActionHandlerContext`. The same snippet also wrote `completed_date`, which
   is `readonly` on `todo_task` and stamped by the object's `beforeUpdate` hook:
   copying it made the action refuse itself against `completed_date_required`.
   Both facts are verified against `task.object.ts` and `task.hook.ts`. The page
   now states the `delete` convention beside where it already states `find`'s.

Folded in: the example's comment attributed to the contract a request the
contract does not make. The contract asks for `ActionHandler`; it says so, and
says why a file of function declarations annotates `ActionHandlerContext`
instead. Comment-only — 11 changed lines, all comments or blank.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
os-sales pushed a commit that referenced this pull request Sep 11, 2026
…ive clause ② sets (#17625)

`Check Changeset` failed the LEVEL AXIS on the previous head: a PR whose clause ②
is declared affirmative must grade at least one package whose `packages/**/src/**`
it moves at `minor` or above, and this changeset graded the only such package
`patch`.

The level is a mechanical floor, not an editorial reading of the act. The
maintainer ruling of 2026-09-04 (decision batch #35, on #15294) is written out
under "WHICH LEVEL" in the `Check Changeset` step: the commit type may raise a
bump but never lower it below what the act requires. The act here re-admits an
input class the merged tree refuses, on an authorisation surface, so the type
stays `fix(runtime)` and only the level moves. The changeset now records that
reasoning so a later reader does not re-grade it back down as a plain bug fix.

⛔ The declaration was not softened to fit the level, and neither the gate nor
the workflow was touched.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
os-project-manager pushed a commit that referenced this pull request Sep 15, 2026
The diff adds MessagingService.registerChannelProvider to an already-published
class. A purely additive widening of a published surface takes at least minor
(maintainer ruling 2026-09-04, decision batch #35), and the PR declares clause
two yes, so patch was a self-contradiction inside one PR.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…on the caller NAMES, not whichever one the session has active (objectstack-ai#16761)

* fix(client): getActiveMember addresses the organisation the caller names

`organizations.getActiveMember(organizationId)` built
`GET /organization/get-active-member?organizationId=...`, and better-auth
1.7.2's handler for that path reads `session.session.activeOrganizationId`
and never looks at `ctx.query`. The query string was dead on arrival: a
permission check for organisation B while A was active answered A's row,
with a 200 and no diagnostic.

The method now asks the question honestly, in two requests: `GET
/get-session` for the caller's own user id, then `GET
/organization/list-members?organizationId=...&filterField=userId&filterValue=<self>&limit=1`,
unwrapping the one-entry page. `list-members` reads `ctx.query.organizationId`
and its rows carry the identical shape, so the signature and the declared
return type are unchanged.

The `get-active-member` ledger row is rebooked `server-only`: no SDK method
builds that URL any more, and `sdk` means "expressed by the SDK".

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

* chore(changeset): declare the getActiveMember addressing fix

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

* chore(plugin-auth): keep the tracker id out of the ledger note string

check:doc-authoring — a runtime string reaches authors and generated
surfaces, none of whom can resolve `#NNNN`; git history keeps the anchor.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

* chore(changeset): grade @objectstack/client minor, not patch

Check Changeset: a PR declaring clause-② yes may not grade a package it
grew `patch`. The maintainer's ruling of 2026-09-04 (decision batch objectstack-ai#35)
holds that a change to a published package's public surface takes at
least `minor`; a commit type may raise a bump, never lower it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

* fix(client): refuse a falsy organizationId in getActiveMember

better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`
on `list-members`, so an empty string fell through to session state and came
back 200 carrying the ACTIVE organisation's row — the same silent substitution
this method was fixed to stop making, surviving on one argument while the
JSDoc says "the GIVEN organisation".

The SDK now refuses it before the wire, in the shape `environment(id)` already
uses. The pinned case asserts nothing reaches the wire at all, and drives
`list-members` with an empty id through the same double to show the fallback
the refusal prevents is real in the fixture, not assumed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

* docs(plugin-auth): the ledger notes name every SDK method that builds each URL

`get-active-member` was rebooked `server-only` because a truth ledger must not
ship a false statement; by the same standard two rows were left incomplete.
`get-session` named only `auth.me` and `auth.refreshToken`, and `list-members`
named only `organizations.listMembers`, while `organizations.getActiveMember`
now builds both. The `invite-member` row is the precedent for exactly this.

Also restores a by-name anchor for the method: after the rebooking it was
pinned by URL through `client-url-conformance.test.ts` but by no `client:` or
`note:` string anywhere in the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

* chore(changeset): carry the breaking-ness and its ADR-0087 disposition

The changeset now carries the `**BREAKING**` banner, one before/after pair per
moved input, and an ADR-0087 `not-required (no-migration-prescription)`
disposition. The level stays `minor`: under the launch-window convention the
level cannot carry breaking-ness, so the banner and the disposition are the
carriers.

Four inputs move, each stated as the response it drew before and the response
it draws now: an id other than the active organisation; an organisation the
caller is not a member of; any id on a session with no active organisation;
and an empty id, which this round refuses client-side.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

* docs(client): correct the pre-fix answer stated for a non-member of the named organisation

The changeset bullet and the `getActiveMember` docblock both said a caller who
was not a member of the NAMED organisation used to draw `400 MEMBER_NOT_FOUND`.
better-auth 1.7.2's `get-active-member` handler reads
`session.session.activeOrganizationId` and never `ctx.query`, so the named
organisation was never consulted at all: such a caller drew a 200 carrying the
ACTIVE organisation's row, and `MEMBER_NOT_FOUND` fired only when the caller
had no row in the active organisation either. The PR's own ablation agrees —
case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e.
the old shape resolved rather than throwing.

Both sentences now state that before-state. The `after` (403) was already
right, and the neighbouring bullets already stated it for every other input.

Prose only: the changeset body ships as CHANGELOG text and the docblock is a
comment. No executable line, no test and no behaviour moves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: huangyiirene <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…s[].children`, and give dashboard global filters a bundle group (objectstack-ai#16883)

* wip(i18n): widen the shared page walk to slots + items[].children; add dashboards.*.globalFilters

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* wip(i18n): translate the newly addressable global-filter keys; fix test typing

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* wip(i18n): regenerate spec artifacts; re-measure the platform-page boundary pin

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* docs(i18n): re-measure the extractor-config note now that the walk reaches under slots; declare the platform-objects bundle rows

The platform-objects extract config explained its missing `pages` key by
the shared walk rooting at `regions[].components[]` only. The walk now also
roots at `slots.<slot>`, so the reason moved: it reaches every component on
the three shipped record pages, and none of them carries an `id`, so the
extractor still offers the page label alone. The changeset gains
`@objectstack/platform-objects` — its shipped Setup bundles carry the new
`dashboards.system_overview.globalFilters.created_at.label` row.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* chore(spec): regenerate the protocol reference on the merged tree

The os-regen driver merged `content/docs/references/api/protocol.mdx` with
exit 0 while keeping one side; regenerating from the merged sources restores
both — main's `droppedFields` prose (objectstack-ai#16930) and this branch's `dashboards`
row gaining its `globalFilters` continuation.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

* fix(i18n): declare the walk's return-shape change and pin the dashboard filter emitter

The changeset graded `@objectstack/cli` and `@objectstack/platform-objects`
`patch` while the diff moves each package's `src/**` and the PR declares
clause ②; the maintainer ruling of 2026-09-04 (decision batch objectstack-ai#35, on objectstack-ai#15294)
puts a purely additive widening of a published surface at `minor` or above, so
both are raised. `walkAddressedPageComponents` is published and its return
value changed shape, so the changeset now carries the `**BREAKING**` banner and
exactly one ADR-0087 disposition, which are the only breaking-ness carriers
during the launch window the level number cannot express.

`collectExpectedEntries` gained a `globalFilters` emitter with no test:
`check:i18n-walk-parity` measures at top-level group granularity and
`dashboards` was already walked, so the sub-group could drift green. The new
pin holds the `name`-keyed and `field`-keyed spellings, the option keys, the
unkeyed filter that is passed over, and the `optionsFrom` filter whose label
stays addressable while its fetched rows do not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

* docs(i18n): correct the walk-contract docblocks the widened walk left stale

Docblocks only — no behaviour change, no test change.

F2 (`packages/cli/src/utils/i18n-extract.ts`, `emitPageComponentCopy`): the
shared walk this function delegates to now roots at `regions[].components[]`
AND `slots.<slot>` and descends `properties.children` AND a panel's
`properties.items[].children`, so the three claims written against the old,
narrower walk were false on this branch: the roots/descent "only" pair, the
emission exception's REGION-LEVEL wording (it is any ROOT-LEVEL `page:header`,
a region's entry or a slot's — the code already reads `!nested`), and the
count of ways `@objectstack/lint`'s `walkPageComponents` is wider. Re-derived
against `packages/lint/src/page-walk.ts`: lint roots at slots (`:166-173`),
descends `items[].children` (`:126-133`), `children` (`:139-142`) and
`body` / `footer` (`:144-145`); the resolver now shares the first three, so
lint is wider in exactly two ways — `properties.body` and `properties.footer`.

F3 (`packages/spec/src/system/i18n-resolver.ts`, `walkAddressedPageComponents`):
the docblock said `children` and `items[].children` are "the slots the walk
owns; everything else on the node is the visitor's". `walkComposition` is
called with the ORIGINAL `component` (`:1851`) and rebuilds the WHOLE `items`
array from that original's entries (`:1814-1822` — non-panel entries copied
across verbatim), then `:1853` spreads it over `next.properties`, so a
visitor's edit to any other `items[*]` key is overwritten. The behaviour is
deliberate and no current visitor writes `items`; only the sentence was wrong,
so only the sentence changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…pes it already published (objectstack-ai#17255)

* fix(engine): ObjectRepository declares the findOne/update shapes it already publishes

`IScopedObjectRepository.findOne` / `.update` declare `Record<string, any> | null`
and `Record<string, any> | number | null`, and `IDataEngine` — the call each of
these forwards to — declares the same. `ObjectRepository` sat between two narrow
declarations and re-widened the value back to `Promise<any>` on the way out, which
`implements IScopedObjectRepository` accepts (a wider return always satisfies a
narrower one) while every call site reaching a repository through the CLASS kept
reading `any`, `ObjectQL.createContext(…).object(n).findOne(…)` included.

Census: one consumer, `engine-filter-alias.test.ts`, which read `.status` off a
value that can be null. Repaired with the file's own `not.toBeNull()` / `!` idiom.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* test(engine): pin that a class-typed `object(name)` hands back a declared repository

Compiler-driven probes (`ts.createProgram`, the idiom
`packages/spec/src/contracts/scoped-context.test.ts` uses) over the exported
class doors — `ScopedContext`, `ObjectQL.createContext`, `sudo()` — asserting the
diagnostic NAMES the declared shape, so neither a bare "it errored" nor an `any`
that erased the type can satisfy it. Anti-vacuity: the legal spelling must
compile clean and no probe may report TS2307.

Probes go through the CLASS, not `HookContext`: `HookContext.api` was narrowed to
`IScopedContext` by objectstack-ai#5945, so a `(ctx: HookContext)` probe is green on both sides
of this fix and pins nothing. Measured, and recorded in the file header.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* chore(engine): changeset for the declared repository return shapes

Graded `patch`: nothing is widened and no symbol is added. The contract already
published these shapes; the implementation is coming back to a declaration it had
already published. Checked against the recorded WHICH LEVEL ruling of 2026-09-04
(decision batch objectstack-ai#35, on objectstack-ai#15294), whose `minor` trigger is additive widening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* chore(engine): re-grade the repository return declarations to minor + BREAKING

Landed precedent PR objectstack-ai#15280 measured: `SqlDriver.update()` and the `TursoDriver.update()`
override moved off an explicit `Promise<any>` onto the shape `IDataDriver` already
declared -- no new exported symbol, `packages/spec` untouched -- and both changesets
shipped `minor` with a **BREAKING** banner. That is this change's shape exactly, so the
earlier `patch` reasoning ("the contract already published it, so nothing moved") is the
very fact pattern that precedent grades `minor`: the emitted `.d.ts` read `any`, so no
caller holding the class was ever asked to narrow.

The ADR-0087 disposition is `no-migration-prescription`, as sibling PR objectstack-ai#16783 used for the
same family. `type-surface-only` is semantically the right category but its predicate 4
cannot address either narrowed symbol; the marker records that measurement.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…e already accepts, and says which convention is the contract (objectstack-ai#17608)

* fix(spec): declare ActionEngineFacade.delete's id array, and say which convention is the contract

`ActionEngineFacade.delete` declared `id: string` while the runtime facade has
always accepted `string | string[]`, iterating one `ql.delete` per id. The
declaration is now `string | string[]`, and the member's doc comment states
what the runtime comment used to record as an accident of two handler suites:
both spellings are contract, the array form is a convenience over the same
per-row path, and it is neither bulk nor atomic.

Two consumers follow from the declaration:

- `packages/runtime`'s "tolerant of both conventions" comment is retired (a
  comment correction only; the arm's behaviour is untouched).
- `examples/app-todo/src/actions/task.handlers.ts` drops the hand-rolled
  `ActionContext` copy of the facade — which existed because the published
  type could not express its array call, and which had already drifted on
  `find` — for the published `ActionHandlerContext`.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* chore(changeset): patch for the ActionEngineFacade.delete declaration (objectstack-ai#15117)

The level is measured, not assumed: nothing is removed and nothing narrows,
no runtime behaviour changes, and the sibling re-declaration of the neighbouring
member (`find`, objectstack-ai#14175) shipped as a patch from the same interface. What ships
is a published type that finally describes behaviour that was already served.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* docs(spec): cite the runtime delete arm by symbol, not by line number (objectstack-ai#15117)

`check:spec-docblock-symbol-anchors` refuses a line number as an anchor form,
and the citation added with the widening (`:1471`) was one — a NEW finding, not
one of the seven day-one residuals. It is now the symbol anchor
`packages/runtime/src/action-execution.ts#buildActionEngineFacade`, which is
also the more honest citation: this card exists partly because the line numbers
the issue quoted had already drifted by three hundred lines.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* fix(spec): grade the delete widening as minor, and re-verify the flagged docs page (objectstack-ai#15117)

Contract review returned FAIL with two must-fixes.

1. The changeset is `minor`, not `patch`. The written rule
   (`.github/workflows/pr-automation.yml`, maintainer ruling 2026-09-04 batch
   objectstack-ai#35) is that a purely additive widening of a published package's public
   surface takes at least `minor`, and a commit type may raise a bump but never
   lower it below what the act requires. The PR's own `Clause-②: yes` line says
   this widens the accept set, in those words. The `find` precedent it leaned on
   does not reach: that was a NARROWING, it landed the day the rule was ruled,
   and the rule disclaims pre-rule `patch` precedents.

   How the wrong level survived local verification is the more useful half:
   `check-changeset-no-major` reads the clause-② declaration from the event
   payload and nothing else, so a local run without `--event` cannot exercise
   the level axis at all. Its exit 0 was recorded as a reading when the
   instrument could not have come back the other way.

2. `content/docs/ui/actions.mdx` — the page the repo's own Docs Drift Check
   flagged on this PR — is re-verified against the rewritten example. Its
   handler snippet still annotated `ctx: ActionContext`, a type that file no
   longer declares; it now imports and annotates the published
   `ActionHandlerContext`. The same snippet also wrote `completed_date`, which
   is `readonly` on `todo_task` and stamped by the object's `beforeUpdate` hook:
   copying it made the action refuse itself against `completed_date_required`.
   Both facts are verified against `task.object.ts` and `task.hook.ts`. The page
   now states the `delete` convention beside where it already states `find`'s.

Folded in: the example's comment attributed to the contract a request the
contract does not make. The contract asks for `ActionHandler`; it says so, and
says why a file of function declarations annotates `ActionHandlerContext`
instead. Comment-only — 11 changed lines, all comments or blank.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* docs(ui): state the true reason a handler must not name a server-owned field (objectstack-ai#15117)

The previous push removed `completed_date` from the handler snippet, which was
right, and then explained it with a mechanism that is false. Re-measured:

- A handler's `ctx.engine` runs ELEVATED — `buildActionExecutionContext` returns
  `{ ...base, isSystem: true }` (`packages/runtime/src/action-execution.ts`) —
  and the read-only strip is gated `if (!opCtx.context?.isSystem)`
  (`packages/objectql/src/engine.ts`). Nothing is stripped on that path, so
  "stripped from the payload before the record is validated" never happens.
- The refusal does not exist either, for any caller. The hook's stamp is
  unconditional on the transition and its own docblock says that is precisely so
  a caller-supplied value is overwritten and survives the strip; a live test
  asserts it — "a caller that still sends `completed_date` is not punished for
  it — the hook value wins". The REJECTED row whose mechanism the page described
  is labelled "Measured before the fix".

The true reason is close to the inverse, and is now what the page says: the
write is not stripped, it LANDS, and on a write that is not a completion
transition the hook does not stamp, so the handler's "now" silently replaces the
real completion timestamp.

I read the objectstack-ai#7036 history at the three sites I cited and wrote it in the present
tense, without reading the four sites the conclusion depended on. A citation
that exists is not a citation that entails.

Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… ARITY case in the conformance kit, and the cube face stops dropping the window (objectstack-ai#17694)

* feat(core): hold every analytics face to the dateRange array ARITY

The shared conformance kit had exactly one array-arm case — a two-element
window — so the arity itself was governed nowhere and each face was free to
invent a reading for `['2026-01-01']`, `[]`, `[a, b, c]` and `[null, null]`.

Adds `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and the case that holds every
REGISTERED face to the rule PR objectstack-ai#17593 already landed on the service-analytics
faces: a non-two-bound array is refused with the ADR-0112
ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope. No existing case is
weakened — the two-element window case is this one's control.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <[email protected]>

* fix(driver-memory): refuse a dateRange array that is not a two-bound window

MEASURED end to end on `49cd71548`, four rows spanning 2020…2099: the cube
face emitted NO time predicate at all for `['2026-01-01']`, `[]` and
`['2026-01-01','2026-01-31','2026-02-01']` — a pipeline byte-identical to one
with no `dateRange` — so the query read ALL of history, and `[null, null]`
compared against the string `'null'` and selected nothing.

The array arm is now judged at the discriminator and yields two bounds or
throws the shared ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope,
which is the rule the kit's new ARITY case holds every registered face to. The
`if (range.length === 2)` guard — the line that dropped the window — is gone,
and `ResolvedDateRange.bounds` is a tuple so it cannot come back.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <[email protected]>

* chore(changeset): dateRange array arity — one reading on every face

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <[email protected]>

* refactor(core): the ARITY finding names which half of the contract broke

Two bounds is necessary, not sufficient: calling `[null, null]` an arity
problem sends the next reader to the wrong line. Surfaced by this change's own
ablation, where a real window smuggled into the not-a-window population was
reported as "every other arity".

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <[email protected]>

* chore(changeset): grade @objectstack/core `minor` — it gains an export

A purely additive widening of a published package's public surface takes at
least `minor` (maintainer ruling 2026-09-04, decision batch objectstack-ai#35, on objectstack-ai#15294):
`ANALYTICS_DATE_RANGE_NOT_A_WINDOW` is absent on `origin/main`, exported from
`analytics-date-range-conformance.ts` and re-exported by the package entry.

`@objectstack/driver-memory` stays `patch` and the changeset now says why: its
public surface is byte-unchanged, and the behaviour it stops producing was a
defect the contract never admitted.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ated bare-root request is not refused (objectstack-ai#17625) (objectstack-ai#17691)

* fix(runtime): canonicalise the API root to the discovery route (objectstack-ai#17625)

`dispatch()` strips one trailing slash, so both root spellings the dispatcher
accepts — `${prefix}/` (arriving as `/`) and `${prefix}` (arriving as ``, the
MSW/base-URL-stripped form) — collapsed onto the empty string. Only the
discovery branch at the foot of the method knew that meant the API root; the
ADR-0069 gate, which runs far above it, did not. That disagreement was
invisible while `isAuthGateAllowlisted` exempted a falsy path, and became a
403 on the bare-root discovery request once the predicate went fail-closed.

Normalising the root to `/` would relocate the 403 rather than remove it: a
segment-less path matches no `ALLOW_ROUTES` entry, and the discovery branch
tests `/discovery` or the empty string, neither of which `/` satisfies. The
root is canonicalised to `/discovery` instead — the route it has always
served — read from one constant by both sites so the two cannot drift again.

`packages/core` is untouched and `ALLOW_ROUTES` is unchanged: the only input
whose gate answer moves is the API root, which gains exactly the exemption
`/discovery` already carried, and gains it by being that route.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c

* docs(changeset): the API root is the discovery route (objectstack-ai#17625)

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c

* fix(changeset): grade the runtime bump `minor`, the floor an affirmative clause ② sets (objectstack-ai#17625)

`Check Changeset` failed the LEVEL AXIS on the previous head: a PR whose clause ②
is declared affirmative must grade at least one package whose `packages/**/src/**`
it moves at `minor` or above, and this changeset graded the only such package
`patch`.

The level is a mechanical floor, not an editorial reading of the act. The
maintainer ruling of 2026-09-04 (decision batch objectstack-ai#35, on objectstack-ai#15294) is written out
under "WHICH LEVEL" in the `Check Changeset` step: the commit type may raise a
bump but never lower it below what the act requires. The act here re-admits an
input class the merged tree refuses, on an authorisation surface, so the type
stays `fix(runtime)` and only the level moves. The changeset now records that
reasoning so a later reader does not re-grade it back down as a plain bug fix.

⛔ The declaration was not softened to fit the level, and neither the gate nor
the workflow was touched.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…nal, matching the body schema of the route it posts to (objectstack-ai#17215) (objectstack-ai#17834)

Fixes objectstack-ai#17215

Clause-②: yes

Declared by the dispatching `domain:cli` seat (objectstack-ai#6024) and unchanged by
the delivered diff: relaxing `redirect_uris` **enlarges a published SDK
method's accepted set**, which is the widening direction and is graded
conservatively regardless of the fact that it restores parity with the
vendor. `needs:contract-review` is on the card; this PR does not enqueue
until an at-tier contract review of record exists, which is the PM
seat's half and is ⛔ not satisfied by green CI.

## ⭐ The falsifier, resolved FIRST

Triage (`5621351376`) fenced this card: before writing the `?`,
establish whether `redirect_uris`' required-ness is a **deliberate
guard** rather than an oversight — RFC 7591 makes `redirect_uris`
required for authorization-code clients, so a wrapper stricter than a
permissive vendor may be correct on purpose. An assertion found ⇒ stop,
write no code.

**Verdict: no such assertion exists anywhere.** Every search below is
reported with its count and a lit control, because a bare "found
nothing" is not a result.

| surface searched | how | hits | lit control (proves the matcher fired)
|
|---|---|---|---|
| the declaration's own file, in context | `grep -n redirect_uris
packages/client/src/index.ts`, **every hit read in context** | **8**,
none an assertion | the same 8 hits are the control — the matcher fires
and each was read, not counted |
| repo-wide `redirect_uris` | `git grep -c` | **20 files**, none an
assertion | `create-client` -> 8 files |
| ADRs | `git grep` in `docs/adr/` | **0** files for `redirect_uris`,
**0** hits for `7591` | `Status` -> **136** ADR files |
| RFC 7591 anywhere in-tree | `git grep 7591` | **20 files** — none ties
7591 to this member's required-ness | `RFC ` -> **150** files |
| guard-shaped prose (`deliberately/intentionally required`, `stricter
than`, `guard rather than`, `on purpose`) | `git grep -niE` over client,
plugin-auth, docs, content, changesets | **12 hits, zero about this
member** | `deliberately` in `packages/client` -> 51 in CHANGELOG alone
|
| the pin test named by the docblock | read in full (253 lines) | pins
the **key set**, never this member's optionality | it pins `client_name`
and `scope` value types one screen up — so the idiom exists and was
deliberately not applied here |
| `oauth-provider-schema-parity.test.ts` | read in full | **0** mentions
of `redirect_uris`, `create-client`, `body` | it is a DB-column parity
gate; its own subject matter is the control |
| PR review threads on the sibling PR objectstack-ai#17209 | REST
`/pulls/17209/comments` and `/reviews` | **0** and **0** | repo-wide
`/pulls/comments` -> **100** returned, so the channel is lit |
| review threads on the commits that CREATED the required-ness | REST
`/commits/{sha}/pulls` | **none — both are direct commits, no PR, so no
review thread ever existed** | the same endpoint resolves PRs for other
shas |
| the sibling issue threads | `objectstack-ai/issues/15447/comments`,
`objectstack-ai/issues/17210/comments` | **13** and **9** `redirect_uris` lines —
every one calls it a *measured mismatch / divergence to report* |
comment counts 10 and 7 returned |

**What the history shows instead of a guard.** The member was required
from the method's first commit, `170ecfa4c` ("feat: add OAuth
application management features"), which declared `client_name: string`
required too. Nine hours later `bc5bd719b` ("feat(auth): update OAuth
application handling and consent flow with improved parameter
management") re-pointed the method at `/oauth2/create-client`, **relaxed
`client_name` to optional**, added the three bogus members objectstack-ai#15447 later
removed — and left `redirect_uris` untouched as unchanged context, with
no comment and a one-line commit message. Both are direct commits with
no PR. Nothing, anywhere, ever stated a reason.

⇒ Per triage's own branch: **no assertion found ⇒ proceed; the `?`
restores parity.**

### ⚠️ Near-misses, so the next reader does not re-file this

The dispatch order handed over two; reading every hit in context turned
up **three more**. None speaks to whether the REQUEST member may be
omitted:

1. `index.ts` "Only `client_id` and `redirect_uris` are always present …
`redirect_uris` is unconditional" — the **response** projection's
serialiser.
2. `index.ts` "⚠️ `redirect_uris` is always `[]` here" — the **public
response** projection.
3. *(new)* `index.ts`, inside this very method's docblock: "the array
the vendor schema requires … `redirect_uris` must arrive **pre-split**".
About the **element shape** (array vs the Console textarea), not about
presence.
4. *(new)* `auth-plugin.ts`: "better-auth's Zod body schema requires
`redirect_uris: string[]`" — about the **type**, and it names its own
failure mode, `expected array, received string`. It is the Console
wrapper's split, in another package.
5. *(new)* objectstack-ai#17210 comment `5642689415` calls the request type "the
vendor shape" while pointing at `redirect_uris: string[]` — an
acceptance row asserting that a prose-only PR moved no type.

## Acceptance 2 — the vendor schema re-introspected at RUNTIME

⛔ Not read from a `.d.ts`. Method as the card prescribes:
`createRequire` the package, `await import` the resolved entry,
instantiate, walk `endpoints`, select the endpoint whose `path` contains
`create-client`, read `options.body`.

⚠️ **The package version has MOVED: the card measured `1.7.2`; the
installed version is `1.7.3`**
(`packages/plugins/plugin-auth/package.json` pins `1.7.3`, lockfile
resolves `1.7.3`, and the package's own `package.json` read off the
resolved entry's directory reports `1.7.3` at runtime). Re-measuring was
therefore load-bearing, not ceremony.

⚠️ A second difference at 1.7.3: a zero-argument `oauthProvider()`
**throws** (`Cannot read properties of undefined (reading
'clientRegistrationAllowedScopes')`), so it was instantiated the way
this repo instantiates it, `oauthProvider({ loginPage, consentPage })`.

```
endpoints walked      : 33
create-client endpoint: key=createOAuthClient  path=/oauth2/create-client
options.body present  : yes   body constructor: ZodObject
member count          : 21   (card recorded 21 at 1.7.2 — no drift)
```

The row under test, read today, with optionality **driven** rather than
inferred:

| member | optionality (driven: `safeParse(undefined)`) | type |
|---|---|---|
| `redirect_uris` | **optional** | optional of array of string |

```
safeParse(undefined).success : true      <- the member may be omitted
safeParse([]).success        : false     <- an EMPTY array is refused
safeParse(["https://a/cb"])  : true
body.safeParse({ client_name: 'probe' }).success : true   parsed keys: client_name
unknown key => success: true | STRIPPED (no catchall — zod default strip, as the card recorded)
```

**All 21 members are optional**, and a body omitting `redirect_uris`
entirely parses `ok`. The divergence the card describes is confirmed at
1.7.3.

⚠️ Carried into the docblock and the changeset, because it is a trap in
the other direction: optional does **not** mean `[]` will do — the
vendor refuses an empty array, so omitting the member and passing `[]`
are different requests and only the first is legal.

## Acceptance 3 — a DRIVEN reading that the call is now expressible

⛔ Not a type-level argument; compiled. `pnpm --filter
@objectstack/client typecheck` -> **exit 0**, and that script is two
halves — `tsc --noEmit && pnpm check:test-typecheck` — the second of
which compiles the test layer under `tsconfig.test.json`.

New pin `registerRedirectUrisOptionalPin17215`, compiled and never
invoked, in the file the docblock already names:

- an `expectTypeOf` on `RegisterRequest['redirect_uris']`, asserted
equal to the union of `string[]` and `undefined` — the parity assertion,
in the file's existing house idiom (spelled in words here because the
body sanitiser eats angle-bracket fragments; the file carries the real
generic syntax);
- `register({ client_name: 'PROBE-17215-OMITTED' })` — **previously
inexpressible**;
- `register({})` — the emptiest legal call;
- `register({ client_name, redirect_uris })` — still compiles,
unchanged;
- plus one runtime `it()` proving the SDK synthesises no default: the
body sent is exactly `{"client_name":"PROBE-17215-OMITTED"}` with no
`redirect_uris` key.

⚠️ **The existing `keyof` key-set equality gives this change ZERO
coverage** — `keyof` is insensitive to optionality, so it reads the same
eleven names in both states. That is why a new assertion was owed rather
than relying on the one already there, and it is stated in the new pin's
docblock. ⛔ The equality itself is **untouched**: `git diff` on that
test file has **0 removed lines** — the file is purely additive.

### Ablation, and a correction I am reporting rather than hiding

⚠️ **My first ablation run was wrong and its green is void.** I
predicted RED, mutated `?` back to required, ran `pnpm --filter
@objectstack/client exec tsc --noEmit`, and got **exit 0**. The cause is
not the pin: `packages/client/tsconfig.json` carries `"exclude": […
"**/*.test.ts"]`, so the bare `tsc` program never contains the pin file.
Proved with `--listFiles`: **0** occurrences of the pin file and **0**
`.test.ts` files in that program, against a lit control of **1** for
`src/index.ts`. That run is **NOT MEASURED**, not a pass.

Re-run against the program that actually compiles the test layer:

```
MUTATION PROVEN ON DISK (optional spelling 1 -> 0, required spelling 0 -> 1, blob differs from HEAD's)
pnpm --filter @objectstack/client typecheck  ->  exit 1
  check:test-typecheck: src/oauth-applications-register-request-members.test.ts: 4 type error(s)

packages/client/src/oauth-applications-register-request-members.test.ts(189,66): error TS2344: Type 'string[] | undefined' does not satisfy the constraint 'string[]'.
packages/client/src/oauth-applications-register-request-members.test.ts(194,50): error TS2345: Argument of type '{ client_name: string; }' is not assignable ...
packages/client/src/oauth-applications-register-request-members.test.ts(197,50): error TS2345: Argument of type '{}' is not assignable ...
packages/client/src/oauth-applications-register-request-members.test.ts(269,41): error TS2345: Argument of type '{ client_name: string; }' is not assignable ...
```

Direction RED as predicted; **count 4 where I predicted 3**, and the
extra is nameable: the runtime `it()` I added also omits the member, so
it stops compiling too. Both legs ran from the committed state under
`trap … EXIT INT TERM` with absolute paths; restore proved by blob
equality with `HEAD:packages/client/src/index.ts` **and** empty `git
diff HEAD` **and** empty `git status --porcelain`. No dist preflight
applies — the pin imports `./index`, a relative source specifier inside
its own package, so no built artifact sits in the resolution path
(`check:test-source-alias` exit 0).

## Acceptance 4 — nothing else in the signature moves

```
1 file changed, 1 insertion(+), 1 deletion(-)   (the type change itself)
-        redirect_uris: string[];
+        redirect_uris?: string[];
```

Member-by-member against the merge base, extracted from both revisions
and compared:

```
members at BASE: 11      members at HEAD: 11
lines IDENTICAL between the two: 10
  client_name?  client_uri?  contacts?  grant_types?  logo_uri?
  policy_uri?  response_types?  scope?  token_endpoint_auth_method?  tos_uri?
the single differing line: redirect_uris: string[];  ->  redirect_uris?: string[];
```

## Acceptance 5 — changeset

`.changeset/17215-oauth-register-redirect-uris-optional.md`, **minor**:
a published package's public surface moves, per the maintainer's ruling
of 2026-09-04 (decision batch objectstack-ai#35) that such a change takes at least
`minor` — the same grade the sibling objectstack-ai#15447 carried. ⛔ Not breaking in
this direction: relaxing a required member to optional keeps every
existing call valid, so the changeset carries no BREAKING banner and
`check:adr-0087-registration` correctly reports no declared-breaking
changeset (exit 0).

## Verification

Exit codes captured by redirect-then-`$?`, ⛔ never through a pipe. Lock
runs read their own `VERDICT command-exit` line.

- `pnpm --filter @objectstack/client typecheck` — **exit 0** (test layer
compiles under `tsconfig.test.json`; 0 files / 0 errors in
`test-typecheck-debt.json`).
- `pnpm --filter @objectstack/client test` — **exit 0**, **43 files /
519 tests passed**.
- `pnpm --filter '@objectstack/client^...' build` — **exit 0**. Required
first: on an unbuilt `dist` the typecheck reported 17 phantom `TS2307
Cannot find module '@objectstack/spec/*'` errors that have nothing to do
with this diff.
- Gate families derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (⛔ never a hand list) and
reconciled: **`✓ 60 derived famil(ies) accounted for — 59 run, 1
NOT-MEASURED, 0 UNRUN`**.
- The first derivation carried a **STALE TREE** warning naming
`scripts/pm/check-half-states.mjs`; rather than run a list about a tree
nobody is on, `origin/main` was fetched and merged (clean, no `os-regen`
deferral, and neither incoming file overlaps this diff) and the families
re-derived at `834e79059`.
- `pnpm check:dual-build-cjs-loads` — **exit 3, `PREREQUISITE NOT
MET`**: it reads built output and 32 packages have no `dist/`. ⛔
Declared NOT MEASURED, never counted as a pass; its own text says "This
is NOT a pass: nothing was measured". CI checks out fresh and builds.
This diff changes one type annotation and adds test code, so it emits no
JavaScript for that gate to read.
- Two others refused their prerequisites on the first pass and were
remediated, ⛔ not counted as passes until re-run: `check:skill-examples`
exit 1 (`packages/client-react/dist` held no declarations) -> after
building `client-react` + `client`, **exit 0**, 258 prose examples
type-check across 3 surfaces; `check:type-check-debt` exit 3 (the
whole-repo re-measure OOMed under `--max-old-space-size=4096`) -> at
8192, **exit 0**, "5 ledger entr(ies) re-measured, 55 raw tsc error(s),
none above its recorded number".
- `pnpm lint` population, run in full rather than narrowed: `eslint .
--no-inline-config` — **exit 0**, **6645 files checked, 0 errors, 0
warnings**, at `834e79059`. Type-aware linting is not enabled
(`eslint.config.mjs` states "no `parserOptions.project`, no typed
`@typescript-eslint` rules"), so per-file verdicts are independent.

## Acceptance notes

- **The `[]` asymmetry, noted not filed.** The TYPE cannot express
"non-empty array", so `register({ redirect_uris: [] })` compiles and the
vendor refuses it at runtime. This is unchanged by this PR — it was
equally true while the member was required, which is also why the old
required-ness guarded nothing the vendor did not already guard better.
Recorded in the docblock and in the new pin so the compiling call is not
mistaken for a legal one.
- **`return-type-precision.test.ts` passes `redirect_uris` at both its
call sites (including `[]`) and both still compile** either way; no
change was needed there.
- The card's item 3 — checking the answer against the two other
registration doors (`POST /oauth2/register` DCR, and the ObjectStack
`sys-oauth-application/register` mount) — is ⛔ deliberately not taken
here: neither door's shape moves, and objectstack-ai#17210 already ruled the mount
stays `server-only`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…on (objectstack-ai#18027)

Fixes objectstack-ai#18003

Writes the **level half** of the pre-launch launch-window exemption into
ADR-0087, per the maintainer's ruling on this card (recorded verbatim in
`5653264942`):

> **A,统一 minor,把规则写进 ADR-0087**

One file, one insertion: 32 lines added to the ratified section
`Ratified: the pre-launch launch-window exemption (majors 12–15)`.
Nothing existing is rewritten — the verbatim sentence both rulings cited
("The exemption covers the *window* only — never the *chain*") is left
byte-identical.

## What the measurement changed about the fix

The card's premise was that the level rule "lives **only in prose**" — a
declared-but-unenforced rule. **Half of that is false, and it changed
the shape of this PR.**

- ✅ **ADR-0087 is silent on the level** — verified across all 928 lines,
not just the cited section. `minor` occurs exactly **once** in the whole
document (line 718), describing four `@objectstack/client` type-surface
narrowings — a different class, descriptively, not prescriptively. All
**41** occurrences of `major` mean the **protocol** major. `patch` and
`semver`: zero.
- ❌ **But the rule is NOT unenforced.**
`scripts/check-changeset-no-major.mjs` refuses any PR that introduces a
`major` changeset. It runs per-PR in `pr-automation.yml`'s `Check
Changeset` job, and it is **armed right now** — the RC exemption that
stands it down needs `.changeset/pre.json` in `"mode": "pre"`, and that
file does not exist on this branch. The level was also already ruled by
the maintainer on **2026-09-04** (decision batch objectstack-ai#35, on objectstack-ai#15294) and its
prose sits in that workflow's `WHICH LEVEL` block.

⇒ So objectstack-ai#16885's `major` was not merely the wrong level — it was
**unimplementable**: CI would have refused the changeset. And the actual
defect is **discoverability, not enforcement**: neither ruling cited the
gate, because nothing in the document they *did* read points at it.

That is why the insertion **points at the gate as the authority instead
of restating its rules**. A second copy of an enforced rule is the drift
this repo forbids (AGENTS.md: "the rule is stated once here and the
script's own header is the authority on detail"), and ADR-0087 already
uses exactly this pattern for `check-adr-0087-registration.mjs`.

## Why the text is shaped this way

The section could be read two opposite ways because **every `major` in
this ADR means a *protocol* major**, while the level a changeset
declares is the **npm** version of the lockstep `fixed` group. The two
move independently, and nothing said so. The insertion makes that the
load-bearing distinction, then states the three consequences: `minor` +
`**BREAKING**` banner + ADR-0087 disposition; an npm major as a planned
act at window close; and a tombstone naming the **npm release**, never
the protocol major.

That last line is the cheap in-scope half of objectstack-ai#18021 (the six tombstones
dating a 17.x removal to "`@objectstack/spec` 18"). ⛔ Those six lines
are **not** touched here — objectstack-ai#18021 remains open and is not addressed by
this PR.

## 维护者速读(草稿)

**改了什么**
往 ADR-0087 那条已批准的「launch-window 豁免」小节里补了一段:退役类改动发 npm
的哪一级。只加不改,原有那句被两条裁决同时援引的原文一个字节都没动。

**为什么改**
同一个上午,两条总监裁决援引同一个约定,给出相反的等级(objectstack-ai#16929 发 `minor`,objectstack-ai#16885 发
`major`),两条都带您的「同意」。根因不是谁读错了,是这份文档里的「major」从头到尾指的都是**协议大版本**,而 changeset
的等级是 **npm 发行号**——两者各走各的,文档从没说过这件事。补上这句区分,并把等级这一轴写明。

**一个和派发前判断不同的实测结果**
这条规则**并非只活在散文里**。`scripts/check-changeset-no-major.mjs` 已经在每个 PR 上拒收
`major`,而且现在是**武装状态**;等级本身您也已经在 **2026-09-04**(决策批次 objectstack-ai#35,objectstack-ai#15294)裁过了。也就是说
objectstack-ai#16885 那条 `major` 根本落不了地——CI
会直接拒收。真正缺的是**可发现性**:两条裁决读的那份文档里,没有任何一处指向这个门禁。所以这段文字是**指向门禁**,不是再抄一遍规则——再抄一份,就是这个仓库反复禁止的那种会各自漂移的第二份声明。

**风险与代价(含回滚)**
风险很低:纯文档,零运行时、零发布物、零 npm 包内容变动,因此无 changeset(`docs/adr/**`
不发布)。唯一的实质代价是这段文字要和门禁保持一致——已经用「门禁的 header 是细节权威」的写法把这个代价压到最小。回滚 = revert
这一个 commit,无任何后续清理,不影响任何在飞的轮次。

**席位意见**

**你要做的**
① 确认这段文字的措辞确实排除了当初那两种读法;② 确认「墓碑写 npm 发行号、不写协议大版本」这句您认可(它是 objectstack-ai#18021
的便宜一半,那六行本身本 PR 没碰);③ 这是受管面,合并由您手动执行——本轮不翻 ready、不入队、不挂 auto-merge。另外下面
Acceptance notes 里有一条给席位的门禁提案,需要您或分诊席决定要不要立卡。

## Acceptance notes

### Zone 3 answer — should a mechanical check compare a retirement
changeset's declared level against this rule?

**No new gate — because the gate already exists, and a second one would
be the duplication this repo forbids.** `check-changeset-no-major.mjs`
already refuses the exact failure this card is about (`major` on a
retirement), per-PR, armed. The card's third axis diagnosed "declared
but unenforced"; the measurement says **declared, enforced, and
unreachable from the document that matters**. This PR fixes the
reachability, which is the whole of the defect.

**But there is a narrower residual, and it is a proposal to the seat — ⛔
not built here** (a gate would widen a governed-surface PR the
maintainer must hand-merge):

- **The gap:** nothing refuses a retirement graded **`patch`**. The
no-major guard has no floor, and its level axis (objectstack-ai#16055) exits 0 with
`not-declared` on any PR that does not declare Clause-②. This is not
hypothetical — objectstack-ai#16885's own dev report (comment `5643769773`) proposed
`'@objectstack/spec': patch` for this very retirement. So three
different levels were live across one class.
- **Where it would live:** inside `check-changeset-no-major.mjs`, as a
third axis in the run that already parses every introduced changeset's
bumps and body. ⛔ Not a new script — the parse, the merge-base
discipline and the self-test harness all already exist there, and a
second script would re-derive them and drift.
- **What it would key on:** the `**BREAKING**` banner, which is already
mandatory during the window and already read by
`check-adr-0087-registration.mjs`. Predicate: a changeset whose body
carries the banner must grade at least one published package `minor` or
above. That keys on a declaration the author already makes, so it needs
no new authoring surface and no taxonomy of "retirement-class".
- **What its lit control would be:** objectstack-ai#16885's own `patch` changeset body
plus a `**BREAKING**` banner — refused; and the same body with the
banner absent — exits 0. Both as `--self-test` fixtures beside the
existing ones, so the checker's own health is graded the way the other
two axes already are.
- **Its end condition is inherited, not new:** it disarms at GA with the
guard around it, when the level becomes the carrier again.

### Out-of-scope, noted, not filed

- `.changeset/pre.json` is absent on `origin/main`, so the no-major
guard's RC exemption is inactive and the guard's enforcing half is live.
Recorded because the guard's own header documents a window in which it
stands down; a future reader checking "is this enforced today" needs the
state, and today it is armed. Not a defect. **承接者:无** — no open PR or
person is touching that file.
- The `check-doc-formula-expressions` gate exits **3** (PREREQUISITE NOT
MET) on a clean worktree until `@objectstack/formula` and
`@objectstack/lint` are built; installing does not build them. Its own
refusal text names the fix and is exemplary, so this is a note, not a
finding. **承接者:无**.

### Gates

22 derived families from the actual changed path (`node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`), all 22 run, exit code captured on the
command before any pipe, reconciled with `--ran`:

```
Run reconciliation — 22 derived, 22 run, 0 NOT-MEASURED, 0 UNRUN.
✓ 22 derived famil(ies) accounted for — a DERIVED zero: all 22 recorded an exit
  code and none of them is 3.
```

`check:doc-formula-expressions` exited **3** on first run (measured
nothing — neither pass nor finding); its prerequisites were built under
the shared verify lock and it was re-run to **exit 0**. Reconciliation
ledger holds real exit codes, not bare "I ran it" lines.

**`pnpm lint` — a measured narrowing, not a skip.** eslint's own config
**ignores markdown**: `isPathIgnored('docs/adr/0087-…md')` returns
`true`, and `lintFiles` on it returns 1 result with 0 errors and 1
warning (the "file ignored" warning itself). The single changed path is
therefore outside eslint's covered set entirely. Invariance for
untouched files: `eslint.config.mjs` configures no type-aware linting
(no `parserOptions.project`, no typed rules), so this diff cannot move
any untouched file's verdict. Read at `93ec068bf`.

**Line budget:** none applies. The token/line ratchet is
`check-skills-token-ratchet.mjs`, scoped to published `skills/**`;
`docs/adr/**` is outside it. No lines were bought by re-wrapping — the
insertion is 32 added lines and 0 modified lines (`git diff --stat`: `1
file changed, 32 insertions(+)`).

**Changeset:** none. `docs/adr/**` publishes nothing from any released
package, so `skip-changeset` applies.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EfsizFDgAcEjpwv4oM3WGT)_

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… at kernel:ready (objectstack-ai#18272)

Part of objectstack-ai#18050

Clause-②: yes

This delivers the card's SECOND half — registration read as a one-shot
verdict. The first half
(the outbox fan-out writing a delivery row for a channel nobody
registered) landed in objectstack-ai#18081 and
is on `main` today, so it is not what this PR changes. See "Premise
re-verified" below: that half
of the card's stated premise has DRIFTED and no longer reproduces.

## Premise re-verified on today's `origin/main`, driven rather than read

Re-derived by symbol (every line number on the card is a stale anchor),
at merge base `e592b4b6b`:

| the card says | measured today |
|---|---|
| inline P0 `fanOut` checks registration | holds —
`getChannel(channelId)`, refusing one line down |
| outbox P1 `enqueueDeliveries` has NO check | **DRIFTED** — objectstack-ai#18081
added one; the loop refuses and counts |
| an unregistered channel gets one row per recipient, dead-lettered on
attempt one | **no longer reproduces** |
| registration is a ONE-SHOT VERDICT (`if (getEmail())` at
`kernel:ready`) | holds, unchanged — this PR's target |

Both directions were driven end to end through a real
`MemoryNotificationOutbox` and a real
`NotificationDispatcher.tick()`, not concluded from the shape of the
loops. A temporary probe
emitted on `['inbox','email']` with `email` never registered:

```
leg A — today's main, guard in place
  rows_written_for_unregistered_channel=0
  after_one_tick=[]
  emit_counts={"enqueued":2,"failed":2,"suppressed":[]}

leg B — the same tree with the landed guard REMOVED (the shape the card describes)
  rows_written_for_unregistered_channel=2
  after_one_tick=[{"status":"dead","attempts":1,"error":"channel 'email' not registered"},
                  {"status":"dead","attempts":1,"error":"channel 'email' not registered"}]
  emit_counts={"enqueued":4,"failed":0,"suppressed":[]}
```

So the card's reported symptom is real and is already cured on `main`;
the probe file was deleted
after both legs ran (restore proven by blob identity, see Evidence).

## What was wrong with the half this PR takes

`messaging-service-plugin.ts` mounted the email and SMS channels inside
a `kernel:ready` hook,
behind `if (getEmail())` / `if (getSms())`. The comment above that guard
reasoned "the dispatcher
looks channels up dynamically, so registering after it is fine" — true
of the dispatcher, and
contradicted by the guard beneath it: the `if` ran exactly once and
nothing revisited it. A
transport that registered later in the same boot — a plugin ordered
after this one registering
from its own `kernel:ready` handler, `kernel:bootstrapped`,
`kernel:listening`, or a runtime mount
— never got its channel, and every `notify` naming it was refused as
"not registered" for the life
of the process, with the transport sitting in the registry.

That is the three-part shape AGENTS.md's "Startup registry reads"
section names: a read of a
still-filling registry, a terminal conclusion from absence, and the
conclusion recorded — here as a
non-registration nothing revisits. `pnpm check:startup-registry-verdict`
is green on it both before
and after, because the AST rule reads a recorded verdict and a mount
that never happens leaves
nothing for it to read.

## The change

`MessagingService.registerChannelProvider(id, resolve)` mounts a channel
that is resolved on EVERY
lookup; `getChannel` asks the provider, and `getRegisteredChannels`
lists only what resolves right
now. The plugin mounts both channels through it, memoising the channel
OBJECT (so it keeps its
identity and its template-store handle) and never the transport's
PRESENCE — that re-read is the
whole fix. This is AGENTS.md's first cure, in the shape
`createLazyCounterStore` (plugin-auth)
established: nothing is resolved at start, so plugin order decides
nothing.

The dispatcher reads the same lookup through
`ChannelRegistry.getChannel`, so the old guard's claim
is finally true end to end rather than contradicted by the code beneath
it.

## What this deliberately does NOT move

⛔ An unmounted channel is still REFUSED, never suppressed. A composition
that never registers the
transport behaves exactly as it does today: the channel is not mounted,
both fan-out paths refuse
it with the same failed `DeliveryOutcome`, no
`sys_notification_delivery` row is written, and
nothing is recorded in `sys_notification.suppressed_channels` — an
absent mount is a COMPOSITION
fact, identical for every tenant in the process, and that column answers
"why can this TENANT not
send" (objectstack-ai#18041's settlement). Whether a MOUNTED channel can send is the
separate question
`isAvailable` answers, and this PR does not touch it.

`channel-availability.test.ts` and `unregistered-channel.test.ts` are
unchanged and green: the
boundary they pin was held, not rewritten to make room. Both existing
plugin pins
("registers the sms channel when an sms service is present" / "does NOT
register the sms channel
when no sms service exists") are also unchanged and green — this PR
changes WHEN the mount is
decided, not what it decides.

The alternative fork — mounting both channels unconditionally and
letting `isAvailable` answer
`transport_not_configured` — was rejected for exactly that reason: it
would move a deployment-wide
composition fact into the per-tenant suppression column, re-opening what
objectstack-ai#18041 settled, and (SMS
implements no `isAvailable`) would turn an absent SMS transport into a
delivery row recorded as
SUCCESS with nothing sent.

## Evidence

**Suite** (affected package, full): `pnpm --filter
@objectstack/service-messaging test` —
`Test Files 44 passed (44) · Tests 467 passed (467)`, VERDICT
command-exit 0.
`pnpm --filter @objectstack/service-messaging typecheck` — VERDICT
command-exit 0, and
`tsc -p tsconfig.json --listFiles` puts both edited test files in the
program (1 hit each), so the
green is attributable.

**Ablations** — one-off, direction predicted RED and observed RED, each
proven to reach disk by
anchor counts and restored by STATE (not by an exit code). No `dist` leg
is owed: the suite is
in-package and vitest resolves these modules from `src`.

| mutation | anchor before/after | result | restore |
|---|---|---|---|
| M1 — the plugin decides the mount ONCE again (`presentOnce`) | 1/0,
`presentOnce` 0/2 | `Tests 2 failed, 7 passed` — "expected [ 'inbox' ]
to include 'sms'" | on-disk == HEAD blob `4e177e236`, `git diff HEAD`
empty, 9 passed after |
| M2 — the service MEMOISES the provider answer (the verdict, moved) |
1/0, `resolvedOnce` 0/3 | `Tests 1 failed, 4 passed` — "expected [
'inbox', 'email' ] to not include 'email'" | on-disk == HEAD blob
`428fc9e93`, 5 passed after |
| premise probe leg B — the landed objectstack-ai#18081 guard removed | 1/0 | 2 rows
written, both `dead` on attempt 1 | on-disk == HEAD blob `428fc9e93`,
anchor back to 1 |

**Gates — derived 64 / run 20 of them / NOT MEASURED 1 / UNRUN 44**
(`node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the
changeset existed; exit codes captured before any pipe, verdicts read
from each gate's own output).
14 of the 15 exit 0: `check:nul-bytes`, `check:test-source-alias`,
`check:cross-package-test-inputs`, `check:engine-double-contract`,
`check:type-check-coverage`,
`check-registry-log-declared`, `check-plugin-teardown-shape`,
`check-undeclared-dep-imports`,
`check-empty-changeset --base origin/main`, `check-changeset-no-major
--base origin/main`,
`check-adr-0087-registration --base origin/main`,
`check-closing-keyword-parity`,
`check:published-files`, `check-comment-mask-adoption`. Plus `pnpm
check:startup-registry-verdict` (exit 0), run because it owns this
defect shape although the
derivation does not name it.

⊘ NOT MEASURED: `pnpm check:type-check-debt` exited **3 — PREREQUISITE
NOT MET** (it wants the
whole-workspace build closure lint.yml builds first). Exit 3 is not a
pass and not a finding;
nothing was measured. The 44 UNRUN are the Lint and Repo Gates farm,
left to CI rather than
enumerated locally — see the follow-up section: one of them turned out
to be a real finding
about this diff, and the count above already includes the gates that
round added.

**Control characters**: a self-scan over the diff's files matched
nothing.

## Clause-②: yes — re-derived from the DELIVERED diff, with controls

⛔ Not from the word `export` and ⛔ not from a bundle grep. Probed
through the published entry
(`exports["."]` to `dist/index.js` / `dist/index.d.ts`) after building
the package:

| probe | reading |
|---|---|
| added `export` lines across the whole diff | **0** — and that is the
trap, not the answer |
| `src/index.ts` (barrel re-export list) | **unchanged**, 0 lines |
| positive control —
`MessagingService.prototype.registerChannelProvider` | **function**, and
declared on the exported class in `dist/index.d.ts` |
| positive control — an already-published member (`registerChannel`) |
function — the probe is not new-blind |
| negative control — `lazyChannelMount` (plugin-local helper) |
`undefined`, and absent from the d.ts |
| negative control — the `ChannelProvider` type | not importable (not in
the export list) |
| negative control — a name that does not exist | `undefined` — the
probe can answer no |

⇒ the published surface of an already-published entry grows by one
callable member, so the
direction is a WIDENING and the honest declaration is `yes`. ⚠️ The
card's claim comment declares
`Clause-②: no` as a prediction; that prediction is falsified by this
diff, and correcting the
card's declaration and carrying the label on the card side are the
seat's acts, not this branch's.
`needs:contract-review` is on this PR.

## Not in scope, recorded

`email-channel.ts` and `sms-channel.ts` still answer `{ ok: true }` when
their transport is absent,
so a direct `send()` from the dispatcher can record a delivery as
success with nothing sent. The
card's body already records it as not this card's fix, and this PR
narrows rather than widens its
reach: with the mount tracking the transport, fan-out cannot reach an
unmounted channel at all.

## Follow-up after review feedback (head `6166e303c`)

Two reds were reported on head `661c9b611` and both were mine. Neither
was answered by loosening
anything.

**1. `Check Changeset` — the changeset grade was the side that was
wrong.** The PR declares
clause ② `yes`, and `.changeset/lazy-messaging-channel-mounts.md` graded
`patch`. Under the
maintainer ruling of 2026-09-04 (decision batch objectstack-ai#35, on objectstack-ai#15294) a purely
additive widening of a
published package's public surface takes at least `minor`, and my own
Clause-② derivation above is
the argument for it: a new callable member on an already-exported class
is exactly that widening.
Regraded to `minor`, and the entry now names the added API so an
upgrading consumer reads what is
new. `node scripts/check-changeset-no-major.mjs --base origin/main`
driven offline against this
PR's own event payload: "✓ LEVEL AXIS: this PR declares clause-② `yes`,
and no package whose
`packages/**/src/**` it moves is graded `patch`", exit 0. The lockstep
cost is real and named
rather than dodged: every publishable package is in the `fixed` group,
so this minor moves the
group's minor — that is the price the ruling sets for the widening, not
a reason to under-grade it.

**2. `Lint & Repo Gates` / `Slot-lookup ratchet` — a real finding about
this diff.** The ratchet is
right and my tests were wrong: both plugin tests this PR added wrote
`const messaging: any = ctx.getService('messaging')`, which is the
erasure shape
`slot-lookup/no-any-assignment` bans, and
`messaging-service-plugin.test.ts` is grandfathered for
its EXISTING sites only — so the file's count grew 2 to 4. Fixed at the
source with the spelling
this same file already uses for its other lookups (`as
MessagingService`). ⛔ The two grandfathered
sites were left alone and `scripts/slot-lookup-baseline.json` is
untouched: sweeping them is the
batch work that owns that ledger, and re-baselining to clear my own red
would be the one move this
gate exists to prevent. Verdict line after the fix: "✓ slot-lookup
ratchet holds: 106 unswept
site(s) in 25 file(s), none new… baseline key set verified against
e592b4b: no files added",
exit 0 (captured before any pipe).

⚠️ Worth recording against my own gate report: this red came out of the
49 I had left to CI. The
reading was contract-legal, but a diff that adds test code doing service
lookups owes
`check:slot-lookup` locally, and I did not run it.

**Re-verified at `6166e303c`**, after both remedies: closure build,
`pnpm --filter
@objectstack/service-messaging test` -> `Test Files 44 passed (44) ·
Tests 467 passed (467)`, and
`typecheck` — one chained run, VERDICT command-exit 0. Both ablations
re-run at this head because
the ratchet fix touched the very file M1's red is read from: M1 still
`Tests 2 failed | 7 passed`
("expected [ 'inbox' ] to include 'sms'"), M2 still `Tests 1 failed | 4
passed`, each restored to
its HEAD blob with `git diff HEAD` empty.

Authored by Claude Code, session `session_01URLHobLUJB9K1ABV6ofdjj`.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants