Skip to content

Commit fa534bd

Browse files
authored
Merge branch 'main' into claude/issue-17385-chartconfig-precedence-half-2
2 parents d60f82c + be7382d commit fa534bd

18 files changed

Lines changed: 1792 additions & 37 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client): the four `packages` READ members declare the stage their door is declared at (#17536)
6+
7+
Clause-②: yes
8+
9+
**BREAKING** for TypeScript consumers — a published TYPE-surface WIDENING, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition below, never by the level). No runtime behaviour changes, and none is possible here: only a declaration moved, and the values these four methods resolve to are the values they have always resolved to.
10+
11+
`ObjectStackClient.packages.list` / `.get` and their `ScopedEnvironmentClient` twins returned `InstalledPackage` — the AUTHORING manifest stage, imported from `@objectstack/spec/kernel`. Since PR #17517 both read doors have been declared at EITHER stage: `ListInstalledPackagesResponseSchema.packages` is `z.array(InstalledPackageAtEitherStageSchema)` and `GetInstalledPackageResponseSchema.data` is that same schema. ⇒ A response the server is declared able to send was one this SDK's own types said could not arrive.
12+
13+
`packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`.
14+
15+
**What the union is.** It is a union over the two manifest stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — which differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did, so code that reads only those members needs no change at all.
16+
17+
**Where the RUNTIME and the TYPE disagree, measured at this head.** The runtime schema is the strict half: `InstalledPackageAtEitherStageSchema.safeParse(row)` answers `success: false` for a row whose `manifest` belongs to neither stage — measured on a `{ bogus: 1, objects: 'not-even-an-array' }` manifest and on an empty `{}` one. The published TYPE is NOT that strict: on the assembled branch `manifest` is declared `Record<string, unknown>`, so both of those same rows COMPILE against the declared return type. ⛔ Do not read this widening as a type-level guarantee about `manifest` — the guarantee is the parse's. The type-level tolerance is a known gap, tracked as **#19324**; its root cause is the deliberate `z.ZodType<Record<string, unknown>, …>` annotation at `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a declaration-chunk ceiling), and it is ⛔ not this change's to fix. It is recorded as a pin in `packages/client/src/return-type-precision.test.ts`, which reddens the day the gap closes.
18+
19+
**What a consumer does, concretely.** A member read off `manifest` on the union arrives as `unknown` (measured: `pkg.manifest.objects` is `unknown`). ⇒ a caller that reaches INTO `manifest` narrows by PARSING the row with a `packages/spec` schema and reading the parse's output:
20+
21+
```ts
22+
import { AssembledInstalledPackageSchema } from '@objectstack/spec/api';
23+
import { InstalledPackageSchema } from '@objectstack/spec/kernel';
24+
25+
const row = await client.packages.get(id);
26+
const parsed = AssembledInstalledPackageSchema.safeParse(row);
27+
if (parsed.success) {
28+
// parsed.data.manifest — the ASSEMBLED stage, object definitions
29+
} else {
30+
const authoring = InstalledPackageSchema.parse(row);
31+
// authoring.manifest.objects — the AUTHORING stage, glob strings
32+
}
33+
```
34+
35+
⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any other structural guess. It separates the stages on NEITHER level: at the type level `pkg.manifest` is the same union inside both branches of that `if`, and at runtime BOTH stages' `objects` are arrays — `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` at the assembled one. (Measured: a row carrying no `objects` at all parses as either stage, which is the right answer for it — the key such a guess would read is not there.)
36+
37+
In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member.
38+
39+
**The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise<InstalledPackage>`. `install` answers the row its own request contract produced (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`), and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned.
40+
41+
The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start.
42+
43+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No metadata key, no authored property, no config field, no accepted request shape and no stored artifact changes spelling or shape: the edit is four declared RETURN TYPES on one SDK class pair plus their docblocks, so `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. The party addressed is a TYPESCRIPT CONSUMER and the delivery channel is the compiler at their own call site — the audience the ADR-0087 ledger explicitly does not serve. The "what a consumer does" paragraph above is a source-code statement about narrowing a union, not a stored-metadata rewrite, which is the distinction #13080 records this refusal cannot make on its own.
44+
`type-surface-only` is NOT claimed here, and it is unavailable on the merits rather than on a resolution defect — measured by driving the gate, not assumed. That category was added for a published TYPE-surface NARROWING, and its predicate 4 (`narrowed-from-erased`) reads the base annotation through `isErasedType`, whose line is "the type IS `any` / `unknown`". At the merge base all four members carried a CONCRETE annotation (`Promise<InstalledPackage>`, `Promise<{ packages: InstalledPackage[]; total: number }>`), so nothing moved off an erased type; and this change runs in the opposite direction from the one the category names. The **BREAKING** banner is carried rather than dropped — that erosion is exactly what #13080 was filed about. -->
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
**ADR-0137 makes field-rule predicate fault semantics part of the contract** (#17778): what SUBMIT and RENDER do when a predicate cannot run.
6+
7+
Clause-②: no
8+
9+
**The predicate fault-semantics contract is recorded, not enforced, by this release.** ADR-0137 states what a field-rule predicate does when it cannot RUN: at SUBMIT a faulting predicate refuses the write and names the field and the rule (D2); at RENDER visibility stays fail-OPEN, so a rule that could not run never hides a control and lets the form write `null` over a column the user never saw (D3); a blank or faulting GATE predicate is diagnosed, never a silent `true` (D4); and the evaluation helper's fallback stays freely specifiable (D5), because fault-to-flag and fault-to-throw both exist only because it is a parameter. Those are consequences CONSUMERS deliver — `packages/spec` carries no business logic — and they land in the ObjectUI half. D1's authoring refusal (an `ast`-only envelope and a blank `source` are refused at authoring) is ruled by decision batch #122 item 2 and ships with the evaluated-slot narrowing that owns it, under that change's own ADR-0087 entry.
10+
11+
**ADR-0089 gains an addendum, not a reopening.** It unified the `visibleWhen` / `visibleOn` / `visibility` family under one name; ADR-0137 owns what that family does when a predicate cannot run, and ADR-0089 itself is unchanged by this release.
12+
13+
**Not carried by this entry: the `cel` / `expression` return-type narrowing to `EvaluatedExpression`.** This card touched that signature too, but main shipped the identical narrowing first, under #18638 (card #15811) — see that release's own changeset for the `EvaluatedExpression` story and the TS2322 it fixes. Restating it here would announce, a second time, a fact this release has already shipped under a different entry.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: the form-row `scale` is bounded at the renderer ceiling of 100 (#19088)
6+
7+
Clause-②: no (narrowing)
8+
9+
`FormFieldBaseSchema.scale` — the per-field override a form row carries — was declared as
10+
any non-negative integer with no upper bound. It is the third declaration of `scale` to
11+
reach the same platform ceiling #18972 bounded on the two in `data/field.zod.ts`: every
12+
renderer that turns a declared `scale` into fraction digits reaches one of two platform
13+
primitives, and both refuse above 100. `Number.prototype.toFixed` throws `RangeError:
14+
toFixed() digits argument must be between 0 and 100`, and `Intl.NumberFormat` throws
15+
`RangeError: maximumFractionDigits value is out of range.` So a spec-valid declaration was
16+
unrenderable by any conforming consumer, and its author got no signal at publish time —
17+
the failure arrived as a render-time crash in someone else's repository. The route from
18+
this row to that reader, measured in the sibling checkout at the `.objectui-sha` pin
19+
`53ded82bf7`: plugin-form copies the row's constraint keys onto the runtime field
20+
(`packages/plugin-form/src/sectionFields.ts:220`, `if (fd.scale != null) base.scale =
21+
fd.scale;`), and the number cell renderer hands that value straight to `Intl.NumberFormat`
22+
(`packages/fields/src/index.tsx:661-667`, `maximumFractionDigits: scale ?? 20`). That one
23+
route carries the premise on its own.
24+
25+
The row now carries that upper bound, and the refusal says **why** — it names both
26+
primitives, the `RangeError` and the legal maximum — so an author reads a platform limit
27+
they can verify rather than a cap somebody chose. The bound is the platform's own: at 100
28+
both primitives are measured to succeed, at 101 both are measured to throw, and a unit
29+
test re-measures that boundary on every run rather than trusting the literal.
30+
31+
**BREAKING** — a form-row `scale` above 100 that parsed clean before is refused at
32+
authoring now. This is a deliberate narrowing of a published accepted set, priced as such
33+
rather than as a tidy-up. The declarations it refuses could only ever have crashed a
34+
renderer: there is no value above 100 that any conforming consumer can render, which is
35+
why the bound is the platform's limit and not a policy number.
36+
37+
Unchanged in both directions: `scale: 100` still parses, `scale: 0` still parses, absence
38+
is still absence, and the malformed-declaration refusals from #8321/#12174 (`scale: -1`,
39+
`scale: 2.5`) keep their existing codes and their existing wording. `precision` is
40+
untouched on this row as on the object-field row — it is a total digit count that reaches
41+
neither primitive, so the renderer-ceiling argument does not carry to it.
42+
43+
The number itself moves into `src/shared/scale-ceiling.ts`, a spec-internal leaf module
44+
that no package entry re-exports, so no published export moves and `check:api-surface`,
45+
`check:export-origins` and `check:declaration-map` all stay green. `data/field.zod.ts`
46+
keeps the module-private copy #18972 minted; a pin asserts the two sites refuse with
47+
byte-identical text, so the duplication is held equal rather than left to drift, and that
48+
file can adopt the shared module later as a pure delete-and-import.
49+
50+
Shipped as `minor` under the repo's launch-window convention, in which
51+
`check-changeset-no-major` refuses `major` and breaking-ness is carried by this banner
52+
plus the ADR-0087 disposition rather than by the level.
53+
54+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key is renamed, retired or reshaped: `scale` keeps its name, its place on the form row and its type, and what moves is the top of one existing key's accepted numeric range. So `objectstack migrate meta` has nothing to visit — there is no stored spelling to rewrite and no FROM side to map, because the values this now refuses have no correct mechanical replacement: 101 fraction digits is not a precision a renderer can honour at all, and picking the display precision an author actually meant is their judgement, not a transform the ledger can carry. A ledger row would therefore have to invent the very semantics ADR-0078 and PD #12 forbid inventing, and the channel that does reach every affected author is the parse refusal itself, which names both primitives, the `RangeError` and the legal maximum at the moment the declaration is written. Measured on this tree at `7e52288463`: 126 `scale: N` declarations across `*.ts` / `*.tsx` / `*.json` / `*.mdx` / `*.yml` / `*.yaml` as the lit control, of which four read above 100 and all four are refusal fixtures or prose about them (two from #18972's tests, two from this branch's) — zero authored declarations above the ceiling. The instrument's radius is this repository's tracked file contents; the known target outside it is a `sys_metadata` row already stored in a running deployment's database, which no in-repo instrument reaches, and #18972 recorded the same blind spot. The other four categories are closed on facts: `@objectstack/spec` publishes to npm and declares no `private` (not `unpublished`); no ADR-0087 id is minted in this diff (not `registered`) and none pre-dating the base covers it (not `already-registered`); and the surface that moves is a Zod metadata schema, not a runtime-only TS interface and not a type annotation (neither `runtime-interface-only` nor `type-surface-only`). -->
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `JobSchema`'s own `@example` no longer opens with `id`, the key retired in 17.0.0 (#19184)
6+
7+
The TSDoc `@example` on `JobSchema` opened with `id: "job_sync_meta"`. `id` was removed in
8+
17.0.0 (#4667, ADR-0049) and is tombstoned a dozen lines below the block that wrote it, so the
9+
example the schema publishes was refused **by that schema** on a verbatim copy:
10+
11+
```
12+
JobSchema.safeParse(<the block as written>)
13+
→ success: false
14+
→ unrecognized_keys: ["id"]
15+
→ "Unrecognized key(s) on this job: `id`. • `job.id` was removed in @objectstack/spec 17.0.0 …"
16+
```
17+
18+
The same object with the line deleted parses, so the one deleted line is the whole fix. An
19+
`@example` is read by whoever copies it before they read the key table — an author, and every
20+
agent writing job metadata from this schema — which is why a key the same file declares dead is
21+
the one thing it must not open with.
22+
23+
Nothing about what a job may be written as changes here: the accept set, the key table, the
24+
tombstone and its prescription are all untouched, and the generated authorable-surface and
25+
JSON-Schema artifacts are byte-identical across the change. What ships is the corrected example
26+
itself — `src/**/*.zod.ts` is part of this package's published `files`, so the block travels in
27+
the tarball an upgrading consumer reads.
28+
29+
Scope, stated because the adjacent block invites it: the file's second `@example` (on
30+
`defineJob`) carries no retired key and is untouched. The package-wide `@example` sweep is its
31+
own card.

‎.github/workflows/lint.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,28 @@ jobs:
409409
- name: Raw control-byte guard
410410
run: pnpm check:nul-bytes
411411

412+
# Gitlink guard (#17472). Refuses an index entry at mode 160000 — a
413+
# SUBMODULE POINTER — that no tracked `.gitmodules` row declares. Placed
414+
# beside the control-byte guard because it is its structural sibling: a
415+
# whole-index population, a hygiene property of what a CLONE receives, and
416+
# a defect whose only native signal is a warning on a command that exits 0.
417+
# WHY mode 160000 and why "undeclared" rather than "none at all" are stated
418+
# and argued once, in the gate script's header — `scripts/check-gitlink-
419+
# declared.mjs`. That header is authoritative and this comment cites it
420+
# rather than restating it. The one-line summary, for whoever is reading
421+
# this because the step just went red: `git add -A` over a nested git
422+
# repository (a linked worktree, a nested clone, a vendored checkout)
423+
# stages ONE 160000 entry at exit 0 with only a `warning:` line, and the
424+
# commit that follows carries a pointer to an object no clone of this
425+
# repository has. A real submodule passes untouched — `git submodule add`
426+
# writes the declaration and the pointer in one act — so a hit here is
427+
# never a submodule you added on purpose.
428+
# ⛔ Not the untracked-signal half: PR #17468 closed that one by ignoring
429+
# `.worktrees/`. This step is the STAGE, and it is path-blind on purpose.
430+
# Reads the index only, no network, well under a second.
431+
- name: Staged gitlinks are declared in .gitmodules
432+
run: pnpm check:gitlink-declared
433+
412434
# The first three of these are the shared modules the two `scripts/**`
413435
# routing gates below DELEGATE their design arguments to (#10608). Both of
414436
# those gates are

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ Column footer summary configuration
189189
| **min** | `number` | optional | Minimum value (for number/currency/percent/slider) |
190190
| **max** | `number` | optional | Maximum value |
191191
| **precision** | `integer` | optional | Total digits (non-negative integer; for number/currency) |
192-
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
192+
| **scale** | `integer` | optional | Decimal places (integer 0-100). The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
193193
| **multiple** | `boolean` | optional | Allow multiple values (for select/lookup/file/image) |
194194
| **label** | `string \| Record<string, string>` | optional | Display label override |
195195
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |
@@ -354,7 +354,7 @@ View filter rule
354354
| **min** | `number` | optional | Minimum value (for number/currency/percent/slider) |
355355
| **max** | `number` | optional | Maximum value |
356356
| **precision** | `integer` | optional | Total digits (non-negative integer; for number/currency) |
357-
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
357+
| **scale** | `integer` | optional | Decimal places (integer 0-100). The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
358358
| **multiple** | `boolean` | optional | Allow multiple values (for select/lookup/file/image) |
359359
| **label** | `string \| Record<string, string>` | optional | Display label override |
360360
| **placeholder** | `string \| Record<string, string>` | optional | Placeholder text |

‎docs/adr/0089-unify-visibility-predicate-naming.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88

99
---
1010

11+
> **Addendum (2026-09-18, #17778) — what the `*When` family does when it cannot RUN is decided by [ADR-0137](./0137-predicate-fault-semantics-are-contract.md), not here.**
12+
> This record unified the family under one NAME and is unchanged by that one. ADR-0137 D1 holds a
13+
> predicate slot to what the engine can actually run — an `ast`-only envelope and a `source` blank
14+
> after trimming are refused at authoring, which is the field-rule row of the rule decision batch
15+
> #122 item 2 gave across every evaluated slot; D2 refuses the SUBMIT loudly on a fault, naming the
16+
> field and the rule; D3 keeps visibility fail-OPEN at RENDER. A reader who arrived here asking what
17+
> a broken `visibleWhen` does should read that record.
18+
19+
---
20+
1121
## TL;DR
1222

1323
One concept — *"show this only when the CEL predicate is TRUE"* — is spelled three

0 commit comments

Comments
 (0)