Skip to content

Commit b80dce9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20476-define-stack-conversions-reach-doors
2 parents a2dce86 + f572a7e commit b80dce9

53 files changed

Lines changed: 2737 additions & 203 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Liveness ledger: `flow.description`, `hook.label` and `hook.description` are now `live`, not `dead`. Studio already shows all three to a human. Ledger data, one README cell per type and one gate test fixture only. ⛔ No schema, parse, `.describe()` or accept-set change.
6+
7+
The ledgers ship inside this package (`files[]` includes `liveness`), and `@objectstack/lint` reads them to decide which authored keys draw an advisory warning. None of the three rows sets `authorWarn`, so the set of warnings does not change.
8+
9+
- **What shows them.** These are display keys, so under the ledger's "Designer previews count as consumers" ruling, being shown to a human is the whole of their claimed effect. Neither `flow` nor `hook` registers its own list columns in the Studio metadata admin, so the Studio metadata list page falls back to its default columns: name, `label` and `description`. The Studio metadata quick-find indexes and shows every item's `label` and `description` too. Each row cites that reader at the `.objectui-sha` pin `dd3f7e1be`.
10+
- **Where the values come from.** Each row names its producer: the Studio route that mounts the list page, the metadata client's `GET /api/v1/meta/:type` read, and this repo's shared list answer (`createMetaListAnswer`), which adds no projection for either type that would drop the keys. A booted read of the showcase app confirms it: `GET /api/v1/meta/flow` served 30 flows and `GET /api/v1/meta/hook` served 4 hooks, each with its authored `label` and `description` and the showcase's project-scoped package id.
11+
- **Still kept, still not warned.** The re-grade reverses no ADR-0033 decision. All three rows stay docs-shaped annotation, deliberately kept and exempt from enforce-or-remove. Each row keeps the note it carried while `dead`, as history.
12+
- The regenerated liveness counts are the `liveness/state-counts/flow.md` and `liveness/state-counts/hook.md` shards. `flow` has 35 live and 5 dead (was 34 and 6). `hook` has 21 live and 1 dead (was 19 and 3). The README's `flow` and `hook` Notes cells no longer list these keys as dead. The liveness gate test that borrowed `flow.description` as its sample `dead` row now uses the `flow.active` tombstone, which the gate holds at `dead`.

‎.changeset/20387-aggregate-one-double.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ the rows path add exact decimals and round once. It was rejected because SQLite'
3939
adds the stored doubles (`0.30000000000000004`), so the rows path would then disagree with SQLite
4040
for exactly `0.1 + 0.2`.
4141

42-
**Residual, stated.** Double sums are added in row order, one after another, as the rows path
43-
adds them. SQLite 3.43 and later adds with compensated summation. So for a group of three or
44-
more fractions, SQLite's native answer can still differ from every other face in the last
45-
place (`0.1 + 0.2 + 0.3`: SQLite `0.6`, every other face `0.6000000000000001`). This was
46-
already true of SQLite's two paths before this change. Two addends cannot differ.
42+
**Residual, stated.** On PostgreSQL and MySQL the double sums are added in row order, one after
43+
another, without compensation. SQLite 3.43 and later adds with compensated summation, and since
44+
#20489 so does the engine's rows path. So for a group of three or more fractions, the PostgreSQL
45+
and MySQL native answer can still differ from SQLite's and the rows path's in the last place
46+
(`0.1 + 0.2 + 0.3`: PostgreSQL / MySQL native `0.6000000000000001`, SQLite and the rows path
47+
`0.6`). Before #20489, SQLite's own two paths differed there too. Two addends cannot differ.
4748

4849
A consumer that compared `sum` / `avg` over a fractional column with a decimal literal on
4950
PostgreSQL or MySQL (`$eq: 0.3`) now gets the answer SQLite and the rows path already gave:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the engine's rows path adds `sum` / `avg` with compensated summation, as SQLite does
6+
7+
Clause-②: no
8+
9+
`engine.aggregate` answers a `sum` / `avg` on one of two paths: the driver's own aggregate, or
10+
the rows path (`applyInMemoryAggregation`), which aggregates `find()` rows in JavaScript and is
11+
taken for a per-aggregation `filter`, a non-UTC date bucket, or a driver without native
12+
aggregation. SQLite 3.43 and later adds with Kahan-Babuska-Neumaier compensation; the rows path
13+
added naively. So on SQLite one query answered two doubles depending on the path. A `number`
14+
column holding `0.1`, `0.2` and `0.3` in one group:
15+
16+
| | `sum` | `avg` | `having { s: { $eq: 0.6 } }` |
17+
|:--|:--|:--|:--|
18+
| SQLite native | `0.6` | `0.19999999999999998` | keeps the group |
19+
| rows path, before | `0.6000000000000001` | `0.20000000000000004` | keeps no group |
20+
| rows path, after | `0.6` | `0.19999999999999998` | keeps the group |
21+
22+
The rows path now adds with the same compensation, transcribed from SQLite's own, so on SQLite
23+
both paths answer the same double, through `engine.aggregate` and
24+
`POST /api/v1/data/:object/query` alike. It is also the more accurate sum: `1e16 + 1 - 1e16` is
25+
`1`, where the naive fold answered `0`.
26+
27+
Unchanged: two addends (the compensated `a + b` is the naive one, so `0.1 + 0.2` is still
28+
`0.30000000000000004`), integers whose running total stays within 2^53, a non-finite total,
29+
`null` and non-numeric cells, and the empty group (`sum` `0`, `avg` `null`). The answer is still a
30+
JS number.
31+
32+
**Residual, stated.** PostgreSQL and MySQL add `sum` / `avg` natively in double without
33+
compensation, and that arithmetic is the database's own. So over three or more fractions their
34+
native path can still differ from the rows path in the last place (`0.1 + 0.2 + 0.3`: native
35+
`0.6000000000000001`, rows path `0.6`). The `@objectstack/driver-sql` entry for the double
36+
accumulation states the same residual: the difference is no longer SQLite's native path against
37+
every other face; it is PostgreSQL / MySQL native against SQLite and the rows path. The
38+
in-memory driver (`@objectstack/driver-memory`) still adds naively in its own `aggregate`, so on
39+
that driver the two paths can now differ in the same last place. An exact `$eq` on a fractional
40+
sum compares doubles: compare with a range.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
A grants resolution with no active organization now applies only the global grants. `resolveUserAuthzGrants` applies a grant scoped to an organization only while that organization is the active tenant, and one rule decides it for all three kinds of grant row it reads: position assignments (`sys_user_position`), permission-set grants (`sys_user_permission_set`) and the organization's own position rows whose bound permission sets it collects (`sys_position`).
7+
8+
**BREAKING** for a principal acting with no active organization. Before, "no organization" read as "every organization": each organization-scoped grant the user held anywhere applied, with no organization boundary left on it. That is the resolution a session falls back to when it names an organization its owner no longer belongs to, so a member removed from an organization kept the capabilities that organization had granted until someone revoked each grant by hand. Such a principal now holds its global grants and nothing scoped to an organization.
9+
10+
- **Unchanged:** a principal with an active organization resolves exactly as before, and a global grant (no organization) applies everywhere as before. Platform-admin standing is unchanged: it was only ever derived from the unscoped `admin_full_access` grant or the declared administrator list, never from an organization-scoped grant.
11+
- **If a principal relied on it:** act in the organization. Select it as the active organization, or mint the API key from a session that has it active, or grant the permission set globally (no organization) when it is meant to apply everywhere.
12+
- **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading.
13+
- **`@objectstack/plugin-security`:** `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve the user in. The access explainer (`explainAccessForCaller`) resolves the explained user in the caller's organization, and the delegator behind an on-behalf-of principal is resolved in the live principal's organization, so the delegated intersection counts the delegator's grants where the request actually runs.
14+
15+
Clause-②: yes (narrowing)
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves: no `packages/spec` schema, key or export changes, and no stored row changes shape, so `objectstack migrate meta` has nothing to rewrite and no conversion entry has anything to convert. What narrows is which already-stored grant rows apply to a resolution that names no organization; the remedy is operational (act in the organization, or grant globally) and is stated above. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers this (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration alone (not `runtime-interface-only` / `type-surface-only`). -->
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a `date` / `datetime` string is written on a calendar day that exists, and a `datetime` string in an ISO 8601 spelling, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026-02-30T10:00:00Z"` is no longer stored as March 2, and `"07/15/2026 10:00"` is no longer read in the server's zone (#20525)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's date / datetime arm: no authorable key, spelling or stored shape of metadata moves, `packages/spec` is untouched, and a stored row keeps whatever it holds. What is refused is a temporal string naming a day that does not exist, or a datetime string outside the ISO spellings, and which instant such a string meant (a host zone, a locale's day order) is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what a `date` and a `datetime` field accept as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above).
12+
13+
Two kinds of string are now refused with `VALIDATION_FAILED` / 400, the field code `invalid_date` and its existing message ("must be a valid date (ISO-8601)" / "must be a valid datetime (ISO-8601)"), naming the field, before anything is written:
14+
15+
- **A day that does not exist**, as a `date` or as the day part of a `datetime`: `"2026-02-30"`, `"2026-02-29"` (2026 is not a leap year), `"2026-04-31"`, `"2026-02-30T10:00:00Z"`. `"2028-02-29"` is a real day and is accepted.
16+
- **A `datetime` string in any spelling but these ISO 8601 ones**, after trimming: `YYYY-MM-DD` (midnight UTC); `YYYY-MM-DDTHH:MM[:SS[.fraction]]` followed by `Z`, a `±HH:MM` or `±HHMM` offset, or nothing (a zone-naive wall clock is UTC, ADR-0074); and `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no zone (UTC the same way). Refused now, for example: `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"2026-07-15 10:00 PM"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026"`, `"2026-07"`, `"2026-07-15t10:00:00z"` (lower case), `"+002026-07-15T10:00:00Z"`, and a space-separated time carrying a zone, `"2026-07-15 10:00:00+08:00"` (write it with a `T`).
17+
18+
The fix is to send the value in one of those spellings — `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` or `"2026-07-15 10:00"` — or a JS `Date`. No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a wall clock with no zone was read in whatever zone the server process ran in.
19+
20+
What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`:
21+
22+
| written | memory | SQLite | PostgreSQL | now, on all three |
23+
|:--|:--|:--|:--|:--|
24+
| `date` `"2026-02-30"` | 201, read back `"2026-02-30"`, a day that does not exist | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` |
25+
| `datetime` `"2026-02-30T10:00:00Z"` | 201, read back `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` |
26+
| `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the server process's zone | the same | the same | 400 `invalid_date` |
27+
| `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` |
28+
| `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` |
29+
30+
The stored instant of a non-ISO `datetime` was a property of the deployment host: the same request landed hours apart on two servers.
31+
32+
What changes: the record validator's `date` / `datetime` arm asks two more questions of a string, on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. Does its leading `YYYY-MM-DD` name a day that exists (month 01..12, day up to that month's length, February 29 only in a leap year)? And, for a `datetime`, is it one of the ISO spellings above? A `Date` names a real instant and keeps its answer.
33+
34+
**Who is affected.** A caller that writes a `date` or `datetime` field as a string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row that already holds such a value keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write it in an ISO spelling. The server import (`POST /api/v1/data/:object/import`) turns a `datetime` cell into ISO text itself before the write, so its `datetime` cells reach this check already converted; a `date` cell naming a day that does not exist (`2026-02-30`) is now a per-row `invalid_date`, where memory and SQLite stored it and PostgreSQL failed the row.
35+
36+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:
37+
38+
- a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`;
39+
- each ISO spelling above, stored as the same instant: `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` (`"2026-07-15T02:00:00.000Z"`), `"2026-07-15 10:00"` and `"2026-07-15T10:00"` (`"2026-07-15T10:00:00.000Z"`, UTC, not the host zone), `"2026-07-15"` (`"2026-07-15T00:00:00.000Z"`);
40+
- a `date` string with a leading real `YYYY-MM-DD`, still stored as that day;
41+
- a `Date`, still accepted; an epoch-millisecond number, still refused with `invalid_date`;
42+
- the year range 0001..9999;
43+
- every `time` value, and every filter comparand (`where`, a per-aggregation `filter`, `having`).
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/trigger-api': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
fix(trigger-api,service-automation): an `api` flow with no per-flow secret is refused, at arm time and at registration (#20529)
7+
8+
Clause-②: no (narrowing)
9+
10+
**BREAKING** — shipped as `minor` under the launch-window convention
11+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
12+
this banner and the ADR-0087 disposition below, never by the level).
13+
14+
ADR-0041's `trigger-api` acceptance criteria name a per-flow secret and HMAC
15+
signature verification. The trigger used to arm a flow's inbound hook without a
16+
secret, with only a warning, and that hook skipped signature verification. An
17+
`api` flow whose start node carries no non-blank `config.secret` is now refused
18+
in two places:
19+
20+
- **At registration** (`@objectstack/service-automation`). `registerFlow` refuses
21+
a flow whose binding resolves to the `api` trigger (`type: 'api'`, or a start
22+
node with `triggerType: 'api'`) when the start node declares no non-blank
23+
`config.secret`, whatever the flow's `status`. The error names the flow and
24+
`config.secret`. The `/automation` create, update and clone doors answer it as
25+
`400 VALIDATION_FAILED`, like every other registration refusal. At boot the
26+
flow is skipped and the existing `[Automation] failed to register flow` warning
27+
names it.
28+
- **At arm time** (`@objectstack/trigger-api`). `ApiTrigger.start()` throws,
29+
naming the flow and `config.secret`, before it stores a hook or subscribes a
30+
queue consumer. The engine logs `Failed to bind flow` and the flow stays
31+
unbound. This covers a host that binds the trigger without the engine. The
32+
arm-time `armed WITHOUT a secret` warning is gone, since that state no longer
33+
exists. Every armed hook verifies the signature on every post.
34+
35+
**Fix.** Give the flow's start node a non-blank `config.secret` and sign each
36+
post with it, as the `x-objectstack-signature` header already documents. A flow
37+
that is only ever started explicitly (`engine.execute()`, or the `/automation`
38+
trigger route) and is not meant to receive inbound posts is an `autolaunched`
39+
flow. Declare it `type: 'autolaunched'`, with no `triggerType: 'api'` on its
40+
start node, and it needs no secret.
41+
42+
Unchanged: a flow that already carries a secret registers, arms and verifies
43+
exactly as before.
44+
45+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, the start node's `config` stays the open record it was, and `hookId` and `secret` are read from it exactly as before. What changes is runtime behaviour for one authored shape, an `api`-kind flow whose start node carries no `config.secret`, which is now refused at registration and at arm time. `objectstack migrate meta` could not rewrite that shape even in principle, because the missing value is a shared secret only the author and the sending system can supply. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers this shape and none is minted here (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

0 commit comments

Comments
 (0)