Skip to content

Commit 0424329

Browse files
authored
Merge branch 'main' into claude/issue-18202-crossref-dependency-aware
2 parents 19db79b + 75237a9 commit 0424329

139 files changed

Lines changed: 5691 additions & 1005 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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
'@objectstack/spec': patch
5+
'@objectstack/core': patch
6+
'@objectstack/cli': patch
7+
---
8+
9+
docs(identity): re-point the cloud-identity `ADR-0024` citations at the records that decide them (#14361)
10+
11+
From this repository's point of view `ADR-0024` names two unrelated decisions.
12+
`docs/adr/0024-mcp-connectors.md` is *MCP Servers as Connectors* — an open,
13+
vendor-neutral tool protocol, with a Decision section numbered §1–§5 and no
14+
D-lettered clauses at all. The identity surface's citations mean something else
15+
entirely: the identity-and-access decision taken in `objectstack-ai/cloud` as
16+
its own ADR-0024, whose open mechanism half has been mirrored into this repo
17+
since 2026-09-07 as
18+
[ADR-0135](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0135-identity-and-access-architecture.md).
19+
A reader following one of those citations landed on a real page about the wrong
20+
subject, which is worse than a dangling id: a plausible-looking record invites
21+
belief rather than a second question.
22+
23+
79 citation lines were read one at a time and re-pointed. 73 mean a clause
24+
ADR-0135 restates and now name it with its letter — D4 (source-of-truth marking,
25+
managed vs env-native), D5.2 (the break-glass last-administrator invariant), D6
26+
(SSO per production environment, including the opt-in DNS domain-verification
27+
clause this tree spelled `ADR-0024 ②`) and D9 (environment users and
28+
organization membership). 6 mean a clause ADR-0135 deliberately leaves in the
29+
cloud record and now carry the anchors gate's cross-repo qualifier
30+
`cloud ADR-0024`: `V1` (the SSO default-role provisioning, the roadmap and
31+
commercial framing) and `§7` (the `ai_seat` synthesis, which ADR-0135 does not
32+
restate).
33+
34+
What actually reaches a consumer of these packages:
35+
36+
- `@objectstack/plugin-auth` — the **operator-facing break-glass refusal
37+
detail** now reads `break-glass invariant, ADR-0135 D5.2 — an environment must
38+
always keep at least one administrator who can sign in`. The condition that
39+
raises it, its status, its error code and the rest of its wording are
40+
unchanged; only the ADR number moves. ⚠️ A deployment that greps that message
41+
for the literal `ADR-0024` should grep for `ADR-0135`. The guard's
42+
registration log line moves the same way.
43+
- `@objectstack/platform-objects` — `sys_sso_provider`'s `domain_verified` field
44+
help text, its `protection.reason`, and the matching leaf in all four shipped
45+
locale bundles (`en`, `es-ES`, `ja-JP`, `zh-CN`).
46+
- `@objectstack/spec` — the doc comment above `AuthConfigSchema`'s
47+
`ssoDomainVerification`, published both in `dist/` and as
48+
`src/system/auth-config.zod.ts`.
49+
- `@objectstack/core`, `@objectstack/cli` — doc comments only, published in
50+
`dist/`; no runtime string and no behaviour.
51+
52+
No behaviour moves. No schema accepts or refuses anything it did not accept or
53+
refuse before, no security or permission semantics are touched, and no ADR
54+
record is written or edited. Bare `ADR-0024` still resolves exactly as it did:
55+
the 15 citations that mean the local MCP-connectors record are byte-identical to
56+
`main`, and `check:adr-anchors` reports the same resolving-citation totals before
57+
and after. Historical archives are deliberately untouched — 36 CHANGELOG lines
58+
across seven packages, and the 22 lines under `docs/adr/`, which is a governed
59+
surface this change does not enter.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
`try_catch`'s catch-region binding is annotated as the plain declared type. `TryCatchErrorValueSchema` declares `code: z.string().optional()`, so the local `TryCatchErrorValue & { code?: string }` intersection in `builtin/try-catch-node.ts` added nothing the exported `TryCatchErrorValue` did not already carry, and the comment paragraph beside it explained a spec/engine divergence that no longer exists (#15669).
6+
7+
**No behaviour change, and nothing executable moves.** The object literal is untouched: `nodeId`, `message`, `code` and `iteration` / `item` are bound under exactly the same conditions as before, so a catch region still branches on `{$error.code}` and still reads an absent `code` as "no classified code", never as "nothing failed". Measured on the built package: `index.js`, `index.cjs`, `index.d.ts` and `index.d.cts` are **byte-identical** before and after; only `index.js.map` / `index.cjs.map` shift (by one byte each), because the replacement comment is two lines longer and the sourcemap encodes line positions.
8+
9+
The annotation was proven redundant before it was removed — `TryCatchErrorValue` and `TryCatchErrorValue & { code?: string }` are mutually assignable, and `TryCatchErrorValue['code']` is exactly `string | undefined` — and the binding it describes is genuinely pinned: dropping `code` from the literal reddens the two `#14419` discriminator tests in `builtin/create-record-duplicate-code.test.ts`.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469)
7+
8+
<!-- adr-0087: registered field-multiple-non-capable-type-refused -->
9+
10+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
11+
(the lockstep convention: `major` is refused by `check-changeset-no-major`, and
12+
breaking-ness is carried by this banner plus the ADR-0087 disposition).
13+
14+
Two definitions of "multi-valued" disagreed, and the user saw the disagreement as
15+
a `400`.
16+
17+
- `FieldSchema` accepted `multiple: true` on **any** type.
18+
- `@objectstack/driver-sql`'s `isJsonField` read the flag raw —
19+
`JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array
20+
column** for it.
21+
- `isMultiValueField` — the published spec predicate consumers shape queries from
22+
— answered **"not multi-value"** for that same field, because `master_detail` /
23+
`tree` / `text` are outside `MULTI_CAPABLE_TYPES`.
24+
25+
So a related list composed `=` against a JSON array column, and the driver refused
26+
the equality family there with a `400`.
27+
28+
In business terms: `multiple` means "this cell holds several values at once", and
29+
that has meaning only on multi-select, multi-record / multi-user and multi-file
30+
fields — exactly what the spec already declares. A child record with several
31+
masters, a tree node with several parents, or a text box holding several texts has
32+
no meaning on any mainstream platform. The declaration was accepted silently, the
33+
UI rendered a single value, the database built a JSON array column, and the
34+
related list answered the user a 400.
35+
36+
FROM → TO, for metadata that used to parse and now fails:
37+
38+
```ts
39+
// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400
40+
{ type: 'text', label: 'Aliases', multiple: true }
41+
{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true }
42+
{ type: 'tree', label: 'Parents', reference: 'category', multiple: true }
43+
44+
// TO — pick the type that actually holds several values…
45+
{ type: 'tags', label: 'Aliases' } // several free-form strings
46+
{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records
47+
48+
// …or drop the key, if the cell really holds one value.
49+
{ type: 'text', label: 'Alias' }
50+
{ type: 'master_detail', label: 'Parent', reference: 'account' }
51+
```
52+
53+
The refusal names the field, its type and the alternative, on the `multiple` path.
54+
`radio` keeps its own narrower 2026-08-22 message (#11437); the two never
55+
double-fire.
56+
57+
**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a
58+
field that was already multi-valued by that predicate keeps its declaration, its
59+
storage and its read path byte-identically. What moved is which declarations can
60+
be newly authored, plus the storage decision for the shapes that are now refused.
61+
62+
**Storage change (`@objectstack/driver-sql`)**: every site that asked
63+
`field.multiple` the question "is this value multi-valued" now asks
64+
`isMultiValueField` — **eighteen expressions across two files**, not one. The
65+
file's own header already called `JSON_COLUMN_TYPES` membership "owned by
66+
`@objectstack/spec`"; that sentence is now true for the `multiple` half too.
67+
68+
- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit),
69+
the read-side deserializer (`isJsonField`, both limbs), the `varchar` width
70+
mirror (`varcharColumnChars`), the cross-field comparison class
71+
(`crossFieldComparisonClass`), the four scalar registries filled by BOTH
72+
`registerObjectMetadata` and `registerExternalObject` (`mediaFields`,
73+
`booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL
74+
temporal-widening candidate sets.
75+
- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn`
76+
disjunct and its `declaresArray` test, which #15771 bound to the writer's
77+
predicate and which a pin test holds equal to it.
78+
79+
Only one of those was named in the ruling; aligning it and leaving seventeen
80+
would have re-opened #11535 in reverse — the DDL writing a JSON column that the
81+
read-side deserializer no longer recognises. A column whose field is multi-valued
82+
by the spec predicate behaves exactly as before; the shapes that change are the
83+
ones the schema now refuses at the entrance.
84+
85+
⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that
86+
interpolate `', multiple'` into an `uncompilableFieldReferenceError` message.
87+
They echo what the author DECLARED back to them; they do not ask whether the
88+
value is multi-valued (the verdict there comes from `crossFieldComparisonClass`,
89+
which is aligned).
90+
91+
⚠️ **Two consequences worth reading before you upgrade.**
92+
93+
1. A **stored** field carrying `multiple: true` on a non-capable type has no
94+
lossless conversion — its column was physically built as a JSON array. The
95+
ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the
96+
structured TODO naming the object, field and type; migrating the data is the
97+
author's judgment call, and the entry states how to prove it.
98+
2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver
99+
-internal column-type alias (`string` / `integer` / `int` / `float` — the
100+
introspected-column spellings) is not a `FieldType`, so a hand-declared
101+
external object that puts `multiple: true` on one of those no longer gets a
102+
JSON column. Declare such a column as `object` or `array` (both are
103+
`JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it
104+
really is.
105+
3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` /
106+
`date` / `datetime` / `time` **ceases to be a supported shape end to end**, as
107+
a consequence of the entrance refusal above. Such a column is no longer a JSON
108+
column, so it is no longer excluded from the scalar read-coercion registries
109+
and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a
110+
`$contains` against one answers the declared no-match rather than a JSON
111+
membership test. Stored data in that shape is the ADR-0087 entry's subject.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/types": patch
5+
"@objectstack/rest": patch
6+
---
7+
8+
fix(spec)!: `timeDimensions[].dateRange`'s array arm is exactly two string bounds, and each refusal ORIGIN gets a true sentence (#17598; ruling A, decision batch #117 item 3)
9+
10+
<!-- adr-0087: registered analytics-date-range-array-two-bounds-required -->
11+
12+
**BREAKING** accept-set narrowing at `timeDimensions[].dateRange` — shipped as
13+
`minor` under this repo's launch-window convention for breaking changes
14+
(`scripts/check-changeset-no-major.mjs`), above the `patch` floor the `fix`
15+
commit type sets, and the same grade the one comparable precedent took: the
16+
STRING-arm closing on this same schema is #16041, and it shipped
17+
`"@objectstack/spec": minor` (`packages/spec/CHANGELOG.md` 17.4.0, under Minor
18+
Changes). ⚠️ Its driver half #16322 declares `"@objectstack/spec": patch`, but
19+
that entry is — in that changeset's own words — "a `PROVENANCE_WAIVERS` row
20+
only", not an accept-set narrowing, so it is not a grade this one is measured
21+
against. The maintainer
22+
ruling calls it a "major changeset"; under the launch window that phrase maps to
23+
the protocol MAJOR the migration registers against (18), not to the changeset's
24+
bump level, which `scripts/check-changeset-no-major.mjs` reserves. The semantic
25+
prescription is registered under protocol major 18 as
26+
`analytics-date-range-array-two-bounds-required`.
27+
28+
### What changed
29+
30+
`AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with **no length
31+
constraint**, so `['2026-01-01']`, `[]` and `['a', 'b', 'c']` were schema-valid.
32+
It is now `z.tuple([z.string(), z.string()])` — a tuple rather than a length
33+
refinement, so the arity is stated to the author's compiler before any parse runs.
34+
Preset names, two-bound windows and an absent `dateRange` parse byte-identically
35+
to before.
36+
37+
`analyticsDateRangeRefusalMessage(input)` becomes
38+
`analyticsDateRangeRefusalMessage(input, origin)`, where `origin` is `'schema'` or
39+
`'runtime'` and is **required** — there is deliberately no default.
40+
41+
### Migration: FROM → TO
42+
43+
| You wrote | Write instead |
44+
| --- | --- |
45+
| `dateRange: ['2026-01-20']` | `dateRange: ['2026-01-20', '2026-01-20']` — a single day is that day as both bounds, the shape the shipped #16322 table already prescribes |
46+
| `dateRange: []` | no conversion. An empty array names no window: write the two bounds the widget was meant to show, or omit `dateRange` (it is optional, and absent means the query is not time-bounded) |
47+
| `dateRange: ['a', 'b', 'c']` | no conversion. Decide which two bounds you meant and write them |
48+
| `analyticsDateRangeRefusalMessage(value)` | `analyticsDateRangeRefusalMessage(value, 'schema')` at a parse door, `…(value, 'runtime')` past one |
49+
50+
`os migrate meta --from 17` emits the first three as a structured TODO rather than
51+
rewriting them: rewriting a one-element array to the same day twice at load would
52+
be the platform deciding, silently, that the author meant one day rather than a
53+
window whose end they forgot, and for the other two shapes there is nothing to
54+
decide from.
55+
56+
### Why it is not a new class of breakage
57+
58+
Since PR #17593 all four analytics faces (`ObjectQLStrategy`, `NativeSQLStrategy`,
59+
the draft-preview evaluator, `DatasetExecutor.runCompare`) already refused anything
60+
that is not exactly two bounds with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, so
61+
every stored range this narrowing refuses was **already failing at query time**.
62+
The contract door was looser than every reader behind it; this moves the refusal
63+
to authoring time and states it accurately. Blast radius is the WIDGET, not the
64+
page: a stored dashboard carrying a now-refused range loses that widget with the
65+
refusal shown and still loads.
66+
67+
### The wording half
68+
69+
The shared sentence ended `"Refused at the schema"` and described every refused
70+
array as `"received an array with a non-string bound"`. For a one-element window
71+
refused by a face **both clauses were false** — every bound present is a string,
72+
and it was refused past the schema, not at it — which is why
73+
`@objectstack/service-analytics` had to overwrite the message rather than reuse it,
74+
leaving one condition with two wordings. The origin is now a parameter and the
75+
`received …` clause names the arity and the bad bound separately, so the sentence
76+
is true for each origin both before and after the arm narrows.
77+
78+
The same rule reaches the WIRE. Narrowing the arm to a tuple gave the union a
79+
second voice: its arm answers `Too small: expected array to have >=2 items` for
80+
the very arity the prescription just prescribed, and the ADR-0114 union
81+
expansion emitted both as `fields[]` entries on `POST /analytics/query` and
82+
`POST /analytics/dataset/query`. `fieldsFromZodIssues` (`@objectstack/types`),
83+
the one mapper both doors report through, now drops the branch issues that land
84+
at the union's OWN path for this refusal — recognised structurally through
85+
`isAnalyticsDateRangeRefusalIssue`, never by message prose. A refusal that names
86+
a DEEPER position keeps it: `dateRange: ['2026-01-01', 3]` still reports
87+
`timeDimensions.0.dateRange.1`, because WHICH bound is not a string is a
88+
location the prescription does not carry. Every other union expands exactly as
89+
before. Client-visible effect: one `fields[]` entry for an arity refusal instead
90+
of two, with the prescriptive one kept.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): four lookup folds no longer hand out `Object.prototype` members for an off-vocabulary key (#17818)
6+
7+
`normalizeFilterOperator` (`/ui`), `resolveDiscoveryEnvironment` (`/api`), and
8+
`pluralToSingular` / `singularToPlural` (`/meta-spelling`, re-exported from
9+
`/shared`) each read a module-level lookup table with a runtime key through a
10+
bare index. Every one of those tables is an ordinary object, so a key that is
11+
not in the vocabulary resolved a member of `Object.prototype` instead of
12+
falling through — and the `?? fallback` each function already writes never
13+
fired, because the inherited member is truthy.
14+
15+
Measured on Node v22.22.2, before and after — each fold evaluated at this
16+
change's implementation and again at its merge base, against the TypeScript
17+
sources that the build and the test run both consume:
18+
19+
| call | before | after |
20+
|:--|:--|:--|
21+
| `normalizeFilterOperator('constructor')` | the `Object` function | `'constructor'` |
22+
| `normalizeFilterOperator('toString')` | `Object.prototype.toString` | `'toString'` |
23+
| `normalizeFilterOperator('valueOf')` | `Object.prototype.valueOf` | `'valueOf'` |
24+
| `normalizeFilterOperator('__proto__')` | `Object.prototype` | `'__proto__'` |
25+
| `resolveDiscoveryEnvironment('constructor')` | the `Object` function | `'development'` |
26+
| `resolveDiscoveryEnvironment('__proto__')` | `Object.prototype` | `'development'` |
27+
| `pluralToSingular('constructor')` | the `Object` function | `'constructor'` |
28+
| `singularToPlural('__proto__')` | `Object.prototype` | `'__proto__'` |
29+
30+
Each function's declared refusal value is what it now answers — the same value
31+
each already gave for an ordinary unknown word such as `nope`. ⛔ No new
32+
fallback was invented. `resolveDiscoveryEnvironment` is the sharpest case: its
33+
own docblock promises "a value guaranteed to satisfy
34+
`DiscoveryEnvironmentSchema`", and for `constructor` it returned a `Function`.
35+
36+
⚠️ **Why `minor` and not `patch`.** The level is carried by this change's
37+
declared contract-review status, ⛔ not by a widening — the guard only NARROWS.
38+
An off-vocabulary key that previously resolved an inherited member now gets each
39+
function's own declared refusal value, and nothing that answered before answers
40+
differently. Nothing in the declared vocabulary moves: every canonical operator,
41+
every `EnvironmentType` bucket, both operator shorthands and every manifest
42+
collection spelling answers byte-identically to before, and the only inputs
43+
whose answer changes are the four prototype-member spellings above, which no
44+
signature ever admitted.
45+
46+
The guard is the `Object.prototype.hasOwnProperty.call(table, key) && table[key]`
47+
shape already landed in `src/data/type-compat.ts`, and carries that site's two
48+
recorded rejections: ⛔ not a null-prototype table (it does not type-check
49+
against the `Record` annotation, and the spelling that does compile silently
50+
costs the exhaustiveness check), and ⛔ not a list of prototype member names
51+
(which the next prototype member defeats).

0 commit comments

Comments
 (0)