Skip to content

Commit 73a2adf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15527-cron-dialect-row-names-croner
2 parents 236f6da + 59953d5 commit 73a2adf

91 files changed

Lines changed: 10050 additions & 537 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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a flat-manifest bundle no longer collects every seed dataset twice
6+
7+
`AppPlugin.start()` collects seed data from two locations — the top-level
8+
`data` field, then the legacy `manifest.data` for backward compatibility. The
9+
legacy read resolves its base as `this.bundle.manifest || this.bundle`, so on a
10+
FLAT bundle — manifest fields written directly on the bundle rather than nested
11+
under `manifest:`, a shape `AppPlugin` supports by design and this repo's own
12+
tests construct — it re-read the very array the top-level read had just
13+
contributed. Every dataset landed in the collection twice.
14+
15+
`mergeSeedDatasets` is a plain `push` with no de-duplication, so both copies
16+
reached the shared `seed-datasets` registry, the inline boot seed, and every
17+
later per-org replay. For an `upsert` dataset with an `externalId` the second
18+
pass is idempotent and the cost is doubled work; for a `mode: 'insert'` dataset
19+
it is the dataset APPLIED TWICE per boot — measured here as two `insert` calls
20+
for one record.
21+
22+
The legacy read now carries the same reference guard its sibling collector has
23+
always carried: `loadTranslations()` performs the identical two-location read
24+
and skips the legacy half when `manifest.translations` IS the array the top
25+
level already contributed. That asymmetry between the two collectors was the
26+
whole defect, so the repair is the sibling's guard rather than a third spelling
27+
of the same idea.
28+
29+
⛔ Not a removal of the legacy read: a bundle whose `manifest.data` is a
30+
genuinely different array from its top-level `data` still contributes both, and
31+
a bundle that nests its manifest is unaffected either way. Nothing is added to
32+
or removed from any published surface.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
The model-facing solution-blueprint mirror can no longer generate an identifier the applier rejects.
6+
7+
`SolutionBlueprintSchema` (what `apply_blueprint` validates against) and `SolutionBlueprintStrictSchema` (the OpenAI-strict structured-output contract the design model generates against) are two declarations of one shape. Their KEYS were pinned by an existing parity test; their VALUES had never been. Every identifier in the lenient schema carried `.regex(/^[a-z_][a-z0-9_]*$/)` and not one identifier in the strict mirror carried it — 20 leaves apart, measured.
8+
9+
The consequence was a build whose approval did nothing. Asked for a CRM, the design model emitted a `company_size` select whose option values came straight off the labels — `1_49` for 「1-49人」. Generating that was legal. Applying it was not: on the turn the user clicked 「确认,开始搭建」 the deterministic confirm replay handed that exact blueprint to `apply_blueprint`, which refused it wholesale (`objects.0.fields.2.options.0.value: Invalid string: must match pattern /^[a-z_][a-z0-9_]*$/`) and staged nothing. The app appeared only because the model noticed the error card and retried with a repaired blueprint the user had never seen.
10+
11+
Every identifier leaf in the strict mirror now carries the same `SNAKE_CASE` constraint the lenient schema enforces — object / field / view / dashboard / widget / app / nav names, `reference`, `nameField`, `columns`, `groupBy`, `measure`, roll-up `object` / `field` / `relationshipField`, condition `field`, and select option `value`. The constraint is emitted into the JSON Schema the model is given (`pattern`), so an out-of-pattern identifier is refused at generation instead of after approval. Option `value` additionally spells out the case that produced the incident: it may never start with a digit, so 「1-49人」 is authored as `size_1_49` — the `label` keeps the human wording untouched, and only the stored value is an identifier.
12+
13+
A new `strict mirror ↔ lenient schema — VALUE parity` test walks both schemas leaf by leaf and fails on any future divergence, the value-side twin of the key-parity gate that already guards this pair.
14+
15+
Refs cloud#1967.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a published `BulkDataEvent` now names the ONE organization the tenant wall named for the batch
6+
7+
`BulkDataEventSchema.organizationId` (`@objectstack/spec/api`, declared by the
8+
contract half) is one organization for a whole predicate write, or absent. The
9+
only bulk producer — `publishBulkDataEvent`, behind the `multi: true` branches
10+
of `update()` / `delete()` — never set it, so every `data.records.updated` /
11+
`data.records.deleted` event read "not asserted" and a tenant-scoped consumer
12+
could deliver nothing per organization on the bulk path. This is the bulk half
13+
of the cross-tenant webhook fan-out leak; the single-record half (`DataEvent`)
14+
landed separately.
15+
16+
The producer now stamps the key from what it already holds — no second query
17+
on the publish path: under `isolated` the caller's active organization (the
18+
Layer 0 wall's equality term), under `group` the caller's membership set when
19+
it names exactly one organization. It is OMITTED — never the caller's active
20+
organization standing in — on a `single`-posture deployment, on an `isSystem`
21+
context (no wall composed), on a multi-membership `group` sweep, when no
22+
enforcement layer injected a posture (the `OS_TENANCY_POSTURE` env fallback is
23+
deliberately not consulted), when the caller may have crossed the wall as a
24+
`PLATFORM_ADMIN` or carries no resolved posture rung, and on an object the wall
25+
does not key on. `absent` here means "the producer did not assert one
26+
organization for the batch", deliberately NOT the `DataEvent` reading
27+
"belongs to no organization".
28+
29+
Which objects "the wall does not key on", stated exactly rather than claimed as
30+
a mirror: plugin-security's Layer 0 composes no wall when its `tenancyDisabled`
31+
input is true or the object carries no `organization_id`, and it folds THREE
32+
clauses into `tenancyDisabled``tenancy.enabled === false`,
33+
`systemFields.tenant === false`, and the deployment's `platformGlobalObjects`
34+
carve-out. The producer reads the registry's binding of that predicate
35+
(`carriesTenantScopeColumn`: the first two clauses plus the column clause) and
36+
answers absent on a federated (`external`) object; a custom
37+
`tenancy.tenantField` is therefore not an exit by itself — the object is walled
38+
iff it carries `organization_id`, and the key follows the wall. The third
39+
clause is deployment-declared and not readable by the engine: a
40+
deployment-exempted object under an armed wall is still stamped with the
41+
caller's organization by this producer alone, and that population's exact
42+
answer is decided by the seam ruled on in #15706.
43+
44+
`patch`, not `minor`: the act adds no member to this package's published
45+
surface. `carriesTenantScopeColumn` is exported at module level inside
46+
`registry.ts` only — `@objectstack/objectql`'s entries (`.`, `./core`) re-export
47+
named members and never `export *`, so `dist/index.d.ts`, `dist/core.d.ts` and
48+
both entries' runtime export lists are unchanged (measured on the built `dist`,
49+
with a firing control) — and the emitted event's member was declared, typed
50+
and paid for at `minor` by the spec half. Producer conformance to an existing
51+
optional member under `fix(` changes no public surface of this package.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: a text operator over a field whose DECLARED type can never store a string is refused at the engine's field-aware door — the contract rows (#15661)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) nothing is renamed, retired or respelled: no metadata key, export or operator changes shape, so `objectstack migrate meta` has nothing to rewrite; an author's remedy is to drop or retarget the mistaken filter condition, which is a change to the query they meant, not to a spelling -->
8+
9+
**BREAKING** accept-set narrowing, declared here and enforced at the engine door: a text operator (`$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) over a field whose DECLARED type is numeric, boolean, temporal (`date` / `datetime` / `time`) or structured JSON is refused before any driver runs — `INVALID_FILTER` / 400, naming the field and its declared type — instead of answering `[]` or a dialect accident. Shipped as `minor` under the repo's launch-window convention for breaking changes. Maintainer ruling 2026-09-05 on #15661 (director decision batch #43, verbatim 「同意」): option C-deny.
10+
11+
The refused set is the union of six EXISTING classes in `field-value.zod.ts`, by reference — `NUMERIC_VALUE_TYPES``BOOLEAN_VALUE_TYPES``CALENDAR_DATE_TYPES``INSTANT_TYPES``CLOCK_TIME_TYPES``STRUCTURED_JSON_TYPES` — so no new vocabulary is minted and a member added to one of those sets later is refused without a change here. String-valued classes pass: `STRING_VALUE_TYPES`, `autonumber`, the option-code classes (single and multi — `tags` included), the record-id classes, and the file classes. `formula` is judged as the field type its declared `returnType` names (`text` passes; `number` / `boolean` / `date` are refused) and is deferred — not judged — when `returnType` is absent. A dotted path into a structured-JSON field stays unjudged, as `filter-dotted-head` already declares.
12+
13+
New on `@objectstack/spec/data` (`filter-text-operator-declared-type.ts`): `TEXT_FILTER_OPERATORS` (pinned equal to `StringOperatorSchema`'s keys), `TEXT_OPERATOR_DOOR_REFUSED_TYPES` / `TEXT_OPERATOR_DOOR_PASSING_TYPES`, `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`, the pure verdict `textOperatorDoorVerdict`, the class table `TEXT_OPERATOR_DOOR_TYPE_CLASSES` (every `FieldType` member exactly once — pinned as a census), the fixture object `TEXT_OPERATOR_DOOR_FIXTURE`, and the derived case table `TEXT_OPERATOR_DOOR_CASES` the engine suite consumes.
14+
15+
The door itself lands in `@objectstack/objectql` under its own engine-lane card (beside the `INVALID_FIELD` unknown-field door, judged against the object's real field map, before any driver dispatch); this changeset is the contract half. Beneath the door nothing moves: a direct driver call — and every evaluator no door fronts — keeps answering `FILTER_TEXT_CASES`' stored-value row (#14079), and the SQL faces' compile-time type-gate set `NON_TEXT_STORED_VALUE_TYPES` stays numeric + boolean, deliberately narrower than the door's set.
16+
17+
What an author sees after the door lands: a condition such as `{ amount: { $contains: '5' } }` over a `number` field, which used to answer an empty list with no signal, is refused with a message naming `amount`, `number` and `$contains`. The condition was a mistake in every measured occurrence (a substring over a number can never match); drop it, or aim it at the text field that was meant.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
A self-registration grant is refused, not silently redirected, when a permission-set row is malformed — and the fourteen dead `{ records }` / `{ data }` normalizer limbs behind that code are gone.
6+
7+
`plugin-auth` carried fourteen array-or-envelope normalizer blocks of the shape `Array.isArray(x) ? x : x.records ?? []` (thirteen on a `records` limb, one on a `data` limb, four of them written as a guard clause rather than a ternary). All fourteen read the same concrete engine — the `ObjectQL` instance the kernel registers as the `objectql` / `data` service — which answers a bare array on every path, populated or empty. The envelope limb was unreachable code that read as a contract, so the next author writing a defensive normalizer here believed an envelope was possible. The limbs are removed, and the three local engine ports that declared `Promise<unknown>` (`BootProbeEngine`, `DevAdminSeedProbeEngine`, `PhoneSmsTemplateEngine`) now declare the array they always returned.
8+
9+
The user-visible change is in `settleSelfRegistrationGrant`, which carried the opposite defect. Its candidate filter dropped any permission-set row whose `id` was missing or blank, silently, before choosing which row to grant:
10+
11+
- When the malformed row was the only one, the operator was told `no active sys_permission_set row named 'X' resolves` — false, since an active row named exactly that was present. That report is the only signal this path emits, and nothing retries it.
12+
- When the malformed row was the **organization-scoped** one and a global row also carried the declared name, dropping it let the `organization_id == null` arm match instead, and the self-registrant was granted the **global** permission set their organization never declared — with a success log and no other trace.
13+
14+
`active !== false` remains a selection predicate: a deactivated set still reports the ordinary "does not resolve". A malformed row is no longer a selection at all — the grant is refused and the report names the malformed row, so the ambiguity is surfaced instead of resolved by accident. A well-formed family grants exactly as before.
15+
16+
**Upgrade note — one family now gets a refusal where it previously got a grant.** If a deployment's `sys_permission_set` already contains a row that is active and carries the declared name but whose `id` is missing or blank, self-registration grants against that name now stop and report, including the case where the malformed row is one nobody was relying on: a malformed **global** row sitting alongside a well-formed **organization-scoped** row used to be dropped silently, letting the org row be granted, and is now refused. This is deliberate — the old behaviour could not tell that family apart from the one where the silent drop granted the *wrong* set — and it is fully reversible without a code change: repair or delete the malformed row and the grant proceeds exactly as before. The refusal is loud and names the row, so it is visible rather than something to discover later; nothing is written while it stands.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
The package-publish seed read-back no longer runs a two-attempt org-then-env ladder whose rungs resolve the same row.
6+
7+
`applyPublishedSeeds` — the route-level seed apply behind `POST /packages/:id/publish-drafts`, which runs for protocols that do not self-apply seeds inside `publishPackageDrafts` — read each just-published `seed` body twice when the session had an active organization: once naming the organization, then once env-wide. The comment above it said the first attempt tried the active org and the second fell back, "and resolving the wrong scope here is what silently produced `0 rows loaded`".
8+
9+
That was true when it was written and is not true now. `seed` declares `allowOrgOverride: false`, and `getMetaItem` resolves `organizationIdForMetaRead(request.type, request.organizationId)` once at its top and spends that binding — never the raw argument — on every read beneath it. The predicate answers `undefined` for every non-overridable type, so both rungs asked the engine the same predicates and served the same answer. Measured rather than reasoned: against the shipping protocol over one store, the two requests produce byte-identical engine reads and byte-identical answers on both the hit and the miss branch, and neutering the second rung reddens nothing on a pinned publish-then-read path (a `view` control confirms the same comparison does separate the two rungs for an org-overridable type).
10+
11+
The read is now a single call naming no organization, and the comment states that the scope is decided by the registry flag and the gate inside `getMetaItem` rather than by this call site — matching the sentence the `app` flip in the same file already carries.
12+
13+
One observable changes, and only on the failure branch: `getMetaItem` answers a wrapper rather than a falsy value for a name it cannot resolve, so the second rung was in practice reached only when the read *threw* — where it repeated the identical failing read and appended the same sentence to the client-facing `seedApplied.errors[]` twice. A failed read-back is now reported once. Nothing about which row a publish resolves, or whether its rows load, moves.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
A session whose active organization is no longer one the user belongs to now resolves with no active organization instead of that one's data.
6+
7+
Under a wall-enforcing tenancy posture (`isolated` / `group`), `resolveAuthzContext` took a browser session's stored `activeOrganizationId` as the request tenant without ever comparing it to the user's current memberships — the framework's only such comparison was gated on an API-key principal. A session whose owner had been removed from an organization therefore kept reading that organization's rows and writing into it until the session expired on its own (7 days by default), including when the removal went through the product's own offboarding path.
8+
9+
That claim is now vetted: if it is not in the caller's `accessible_org_ids`, it is dropped and the context resolves with no active organization at all, which the tenant wall already fails closed on (reads resolve to nothing; a tenant-scoped write is refused by ADR-0123 D2). The principal is **not** refused — a session is a person who may hold memberships elsewhere, so they stay signed in and can switch to an organization they are actually in. The API-key arm is unchanged: a key is its organization binding and is still refused outright. The wire is unchanged; the drop is reported to the operator as a single server-side `warn`.

0 commit comments

Comments
 (0)