Skip to content

Commit 8b422a7

Browse files
committed
Merge origin/main into claude/issue-11819-adr-0125-record-drift
Resolve AGENTS.md: re-apply the PD #15 trigger correction onto the reflowed 120-byte layout. The corrected sentence is longer and the paragraph has zero lossless rewrap headroom, so the correction costs one line; CEILINGS raises AGENTS.md 1149 -> 1150 with the maintainer's 2026-08-25 ruling quoted in place.
2 parents 1e30b1c + 71f9cd1 commit 8b422a7

558 files changed

Lines changed: 50216 additions & 4615 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(spec,runtime): refuse the doubled post-success navigation channel on a `type: 'script'` action (#11519)
7+
8+
**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under
9+
the repo's launch-window convention for breaking changes.
10+
11+
Two independent channels could name a post-success destination for one
12+
`type: 'script'` action: the declared `onSuccess` block (`{ navigate, openIn }`,
13+
validated and visible in metadata) and the handler-returned `{ redirectUrl }`
14+
convention (runtime-only). The spec ruled each surface's default in isolation
15+
and said nothing about an action carrying both — so the renderer had to pick,
16+
and the pick lived only in one renderer's implementation (declared `onSuccess`
17+
wins, objectstack-ai/objectui#5933). Maintainer ruling 2026-08-24: refuse the
18+
doubled channel; ⛔ no `precedence` contract field.
19+
20+
The measured static-knowability partition:
21+
22+
- **Authoring-time refine (spec):** "the handler can return `redirectUrl`" is
23+
runtime-only in general (`target` names an opaque registry entry;
24+
`HookBodySchema` declares no return contract) — but `opensInNewTab: true` is
25+
a schema-visible declaration of the handler-redirect channel (its contract is
26+
"pre-open a tab, then drive it to the handler's returned `redirectUrl`").
27+
A `type: 'script'` action declaring `onSuccess` beside `opensInNewTab: true`
28+
is now **rejected at parse time**, with guidance naming both channels and the
29+
remedy. Previously the pair parsed clean and one declaration was silently
30+
dead at render.
31+
- **Dispatch-seam diagnostic (runtime):** the runtime-only remainder — a
32+
handler that actually returns `{ redirectUrl }` while the action declares
33+
`onSuccess` — now logs a loud `[action-contract]` warning at both dispatch
34+
surfaces (the REST `/actions` route and the MCP `run_action` bridge), naming
35+
the action, both channels, the interim winner and the remedy. Observe-only:
36+
the wire is untouched and the interim renderer precedence stands until the
37+
author takes the remedy.
38+
39+
Single-channel declarations are untouched and pinned byte-identically: only
40+
`onSuccess`, only `opensInNewTab` (with or without `newTabUrl`), and
41+
`opensInNewTab: false` beside `onSuccess` all parse exactly as before. The
42+
corpus was measured at zero doubled producers (this repo's examples and
43+
platform metadata, objectui metadata, and the cloud SSO handoff producers per
44+
the #11519 measurement), so no shipped metadata is affected.
45+
46+
**Migration.** An action refused by the new refine must pick its one
47+
destination: keep `onSuccess` and drop `opensInNewTab` (and stop returning
48+
`redirectUrl` from the handler), or keep `opensInNewTab` + the handler
49+
redirect and drop `onSuccess`. Which channel is right is an authoring decision
50+
the metadata cannot make for you, and zero such actions exist in any measured
51+
corpus.
52+
53+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over a pair of existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal is the channel that reaches an affected author, at the parse site, carrying the remedy; choosing which of the two declared destinations to keep is an authoring decision no migration entry can perform on an upgrader's behalf — and the measured population of affected sources is zero in every corpus. -->
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): enforce the documented `newTabUrl` / `opensInNewTab` co-constraint on `ActionSchema` (#11842)
6+
7+
**BREAKING** accept-set narrowing on `ActionSchema`, shipped as `minor` under
8+
the repo's launch-window convention for breaking changes.
9+
10+
`newTabUrl`'s doc has always said "Only valid together with `opensInNewTab`",
11+
and every renderer read point agrees: objectui's pre-opened-tab wrapper reads
12+
the key only behind `action.opensInNewTab && newTabUrl`, and no other path
13+
reads it at all. Nothing on the refine chain enforced the pairing, so an
14+
action declaring `newTabUrl` without `opensInNewTab: true` parsed clean and
15+
the key was silently inert — the ADR-0078 declared-but-unenforced shape,
16+
arriving through a documented co-constraint rather than a missing key.
17+
18+
`ActionSchema` now **rejects at parse time** an action declaring `newTabUrl`
19+
whose `opensInNewTab` is not `true`, with guidance naming the pre-opened-tab
20+
contract and both remedies (declare the flag if a pre-opened tab is intended;
21+
otherwise delete the inert key — behavior is unchanged either way it was
22+
already behaving, because the lone key was never read). An explicit
23+
`opensInNewTab: false` beside `newTabUrl` is refused too, deliberately:
24+
unlike the #11519 doubled-channel rule, `newTabUrl` has no meaning outside
25+
the pre-opened-tab flow, so a declared-off channel leaves the key exactly as
26+
dead as an undeclared one.
27+
28+
The legal pairing is untouched and pinned byte-identically: `opensInNewTab:
29+
true` + `newTabUrl`, `opensInNewTab` alone, and `opensInNewTab: false` alone
30+
all parse exactly as before. The corpus was measured at zero lone-`newTabUrl`
31+
producers (this repo's examples and platform metadata, objectui's fixtures
32+
and renderer read points, and the cloud SSO producers, which declare the pair
33+
correctly — re-measured at claim per the triage requirement), so no shipped
34+
metadata is affected.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over a pair of existing keys: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The refusal is the channel that reaches an affected author, at the parse site, carrying both remedies; whether a lone `newTabUrl` meant "add the flag" or "delete the leftover" is authoring intent no migration entry can decide on an upgrader's behalf — and the measured population of affected sources is zero in every corpus. Mirrors the disposition of the adjacent #11519 narrowing on the same schema. -->
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): authorize before the break-glass guard on `POST /api/v1/auth/admin/remove-user` (#11477)
6+
7+
The break-glass last-local-credential guard is registered as a global better-auth
8+
`hooks.before`, which runs ahead of an endpoint's own middleware. On
9+
`/admin/remove-user` — served directly by better-auth's router, whose
10+
`adminMiddleware` establishes only a session, with the role decision landing
11+
later inside the vendor's handler — that ordering let the guard's lookup and its
12+
distinctive refusal be reached by any **authenticated** caller before either
13+
authorization layer had run. Because that refusal is target-dependent, the
14+
refusal itself carried a per-record fact about a user the caller was not
15+
entitled to ask about.
16+
17+
`/admin/ban-user` already ran the same guard **after** authorization: #9652
18+
shades that path with an ObjectStack raw mount whose platform-admin gate fires
19+
first. One guard, two routes, opposite orders, and nothing asserting either.
20+
21+
`/admin/remove-user` now carries the same shading, converging the whole
22+
`/admin/*` family on **authorization before the guard**. The mount reuses the
23+
landed #9652 / #9653 pattern and introduces no new mechanism.
24+
25+
What changes is **when** the guard decides, never **what** it decides:
26+
27+
- an anonymous caller still gets `401 UNAUTHENTICATED`;
28+
- an authenticated non-admin now gets `403 PERMISSION_DENIED` for every target,
29+
so the guard is unreachable before authorization and its answer no longer
30+
varies with the named user;
31+
- a platform admin is unaffected in every respect — the mount **delegates** into
32+
better-auth rather than re-implementing removal, so the path-keyed hook still
33+
fires and the guard still refuses the removal of the last local password
34+
login, and admission remains the vendor's own decision (#9969).
35+
36+
An ordering pin ships with the fix so the sequence is mechanically checkable
37+
rather than re-argued: it asserts that one authenticated non-admin naming two
38+
different targets receives **indistinguishable** responses, and — so the pin
39+
cannot be satisfied by deleting the guard — that an admitted platform admin
40+
still hits the guard's refusal, and still succeeds on an ordinary user.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
`ObjectQLStrategy` now refuses a cross-object leaf in a compiled measure's own `filter`, on both of its doors, instead of sending it to an engine that cannot join (#11461). This is the third producer of a predicate on that path — after the caller's `where` and the dataset's definition-level `filter` (#10861) — and the one `filterMemberView` did not fold in: #10413 phase 2 lowers `measureFilters[m]` onto that measure's `aggregations[].filter` entry (#10576), and the envelope check enumerated only two origins while its `query.measures` arm read each measure's resolved *field* and never its filter.
6+
7+
Measured on one fixture before the change, both doors in one run: a measure declaring `filter: { 'account.region': 'West' }` on a cube with `include: ['account']` was ACCEPTED, `engine.aggregate` received `{field:"*",method:"count",alias:"west_count",filter:{"account.region":"West"}}`, and an honest evaluator answered `west_count: 0` where the truthful answer was `2` — beside a correct `total_count: 3`, so the wrong number came back wearing the same response shape as the right one. The `/analytics/sql` echo rendered `COUNT(CASE WHEN account.region = $1 THEN 1 END)` over a `FROM` carrying no join at all. Both doors now answer `INVALID_FIELD`/400 before the engine is reached, naming the offending field, the dataset, and — the locator neither sibling refusal has — the measure whose declaration holds the leaf.
8+
9+
Ordinary per-measure filters are unaffected and still reach the engine carrying their own `aggregations[].filter`, and a cross-object filter declared on a measure a query does not ask for changes nothing: only the measures in `query.measures` are judged, which is exactly the set both doors lower. The same definition remains valid on a native-SQL driver, which the refusal says.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
Correct what `sys_approval_request.payload_json` is documented to be FOR — it is audit evidence served redacted per reader, not a notification source
6+
7+
The object's module docstring — which ships to consumers in the package's type
8+
declarations — justified the snapshot column with: *"used by notifications so
9+
they can render before the record is locked or changed."* That consumer does
10+
not exist. Measured against every `this.notify(...)` call site in
11+
`approval-service.ts`, all **12** of them, each passes a payload of
12+
`{ title, message, actionUrl }` (two also carry `actions`), built from
13+
`object_name` / `record_id` and the caller's own comment. **None** reads
14+
`payload_json` or the parsed `payload`.
15+
16+
This is more than tidiness: that sentence was the only documented
17+
justification for the column holding a *full* row, and it was cited as such
18+
during the #10749 consumer inventory before anyone checked it. The docstring
19+
now states the real reason — the snapshot is retained as **audit evidence of
20+
what was actually submitted**, so the column stays whole at rest, and is served
21+
**redacted per reader** by the subject object's field-level read controls via
22+
`getReadableFields`, on the approvals-inbox door and the generic data door
23+
alike (#11039).
24+
25+
The field's own `description` is deliberately unchanged: `Record snapshot at
26+
submission time` is accurate, and it — unlike the JSDoc — is the string
27+
extracted into the four generated i18n bundles, so no translation leaf moves
28+
and no locale is left holding an English seed.
29+
30+
Also carried in the same pass, the residual documentation the #10749 closure
31+
assigned to the next docs touch in this lane: `payload-redaction.ts` recorded
32+
`hidden`-vs-serialization as an **open** `packages/spec` question, and it has
33+
since been ruled (maintainer, 2026-08-24, applying the 2026-08-12 lineage).
34+
That paragraph now states the ruling — **`hidden: true` stays UI-only;
35+
`internal: true` is the serialization primitive** — so an author who needs a
36+
field kept out of read results is pointed at `internal: true` (#7728,
37+
ADR-0049) rather than at `hidden`, which never governed serialization.
38+
39+
Documentation only: no runtime behaviour, no schema field, and no public type
40+
signature changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': minor
4+
'@objectstack/verify': patch
5+
---
6+
7+
feat(spec,plugin-auth)!: one declared audience posture — `invite_only | email_domain | open`, default `invite_only`
8+
9+
**BREAKING CHANGE (ships as `minor` under the launch-window rule; every publishable package rides the fixed group).** "Who may become a user of an environment's apps" is now ONE declaration instead of an emergent property of five switches — and its default flips to the safe end.
10+
11+
- New authorable surface `auth.audience` on `AuthConfig` (`@objectstack/spec/system`): `posture` (`invite_only` | `email_domain` | `open`), `allowedEmailDomains` (required non-empty for `email_domain`), `selfRegistrationPermissionSet` (required whenever the posture permits self-registration; `admin_full_access` refused). Off-vocabulary postures and inert declarations (domains outside `email_domain`, a permission set under `invite_only`) are refused at parse AND at plugin-auth's config entry — never coerced.
12+
- **FROM:** an undeclared audience meant open email/password self-registration with no email verification, and self-registrants implicitly fell back to the `member_default` permission set. **TO:** an undeclared audience IS `invite_only` — self-serve sign-up (email/password, social-provider OAuth JIT, magic-link/OTP/phone/anonymous, and any unclassified creation method) is refused `403 SELF_REGISTRATION_CLOSED` unless the address holds a pending `sys_invitation` (the first account on a fresh install is exempt — the bootstrap bypass). One-line fix for deployments that mean to stay open: declare `auth: { audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' } }`.
13+
- `email_domain` admits only allowlisted domains (`403 EMAIL_DOMAIN_NOT_ALLOWED` otherwise; exact case-insensitive match, subdomains not implied, `+tag` local parts irrelevant). Any self-registration-permitting posture FORCES `requireEmailVerification` on (an explicit `false` beside it is refused at boot) and grants each self-registrant the DECLARED permission set (`sys_user_permission_set`); a declaration that cannot be resolved refuses admission (`403 AUTH_CONFIG_ERROR`) rather than admitting ungranted.
14+
- Operator-driven creation is never posture-gated: admin create-user / bulk import, SCIM provisioning, and JIT through operator-registered identity providers (`oidcProviders`, `@better-auth/sso`) keep working under every posture.
15+
- `/api/v1/auth/config` now serves `features.audiencePosture` and mirrors the forced verification flag; `SELF_REGISTRATION_CLOSED` and `EMAIL_DOMAIN_NOT_ALLOWED` are registered in the ADR-0112 ledger.
16+
- The BOOTSTRAP bypass counts non-system HUMANS, not `sys_user` rows, so a database still carrying the legacy `usr_system` service row is still a fresh install; the same predicate now backs the dev-admin seed's own precondition. The `emailAndPassword.disableSignUp` bootstrap bypass reads it too.
17+
- `@objectstack/verify`: `stack.signUp(...)` seeds a pending `sys_invitation` for the address before signing up, so harness fixtures that mint a second/third identity enter through the invitation carve-out under the new default. Fixtures asserting on their environment's pending invitations should filter by their own `organization_id` (the harness rows carry `org_verify_audience_gate`).
18+
19+
<!-- adr-0087: registered audience-posture-default-invite-only -->
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
fix(core): `autoRestart` now fires for a health check that throws or times out, not only for one that returns a failure (#11852)
6+
7+
`PluginHealthMonitor.performHealthCheck` reaches its failure handling by two
8+
disjoint routes, and only one of them could ever restart the plugin.
9+
10+
A check that **returned** a failure (`false` or `{ status: 'unhealthy' }`)
11+
incremented `failureCounters`, cleared `successCounters`, and — once
12+
`failureThreshold` consecutive failures accumulated — consulted `autoRestart`
13+
and restarted the plugin. A check that **threw** took a separate `catch` block
14+
that incremented `failureCounters` and stopped there: it never cleared
15+
`successCounters` and never read `autoRestart`. Because `raceCheckTimeout`
16+
rejects rather than resolving, every `timeout` overrun lands in that `catch`,
17+
so a plugin that hung was marked `failed` and never restarted no matter how
18+
many rounds passed or what `autoRestart` said. The severer of the two failure
19+
modes was the one that could not trigger recovery.
20+
21+
Both routes now funnel into one `recordFailedRound` step that owns the
22+
counters, the `failureThreshold` comparison and the `autoRestart` decision, so
23+
a thrown or timed-out check is restart-eligible on exactly the same terms as a
24+
returned failure.
25+
26+
The per-route *status* label is deliberately unchanged: a throw is still the
27+
separate `failed` status applied immediately with no threshold, as
28+
`content/docs/protocol/kernel/lifecycle.mdx` documents. Only the counters and
29+
the restart decision are shared — those are what `failureThreshold` and
30+
`autoRestart` declare, and neither names a route.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`.

0 commit comments

Comments
 (0)