Skip to content

Commit 3143132

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15747-execution-context-envelope
2 parents 7ba5f7c + ad0b3e7 commit 3143132

22 files changed

Lines changed: 1095 additions & 35 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The Expression Protocol dialect table no longer names `cron-parser` as the `cron` engine. That package is not a dependency of any ObjectStack package; the row shipped to authors through the generated reference page (`content/docs/references/shared/expression.mdx`) and pointed them at the wrong library for field counts, alias vocabulary and second-field semantics.
6+
7+
The row now says what the code does: no cron syntax is judged at parse time; `croner` evaluates a cron expression only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule``CronJobAdapter`, where an invalid pattern is refused); every other cron-typed slot is parsed and reaches no engine; and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. Documentation only — no schema, accept set or behaviour changes.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os diff` with no path arguments no longer prints its usage error on stdout — in either face.
6+
7+
The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine.
8+
9+
The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves:
10+
11+
- **the exit code is still 1**, so a consumer branching on exit status sees no change at all;
12+
- **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines;
13+
- **nothing is accepted or rejected differently** — no invocation that worked before fails now.
14+
15+
⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today.
16+
17+
This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) A NON-BREAKING ADDITION, registered here in writing because ADR-0087's registries have no additive entry kind (their three tables are semantic TODOs, retired keys and retired defs, and `spec-changes.json`'s `added[]` is the release-time export diff of `@objectstack/spec`, which platform-object columns are not on). Two nullable `text` columns are added to two `isSystem` platform objects; no metadata key, export, config field or stored shape is renamed, retired, re-typed or tombstoned, so `objectstack migrate meta` has nothing to rewrite and no consumer has to change anything. The physical columns are provisioned by boot schema-sync, which is additive-only (`initObjects` creates missing columns and never alters existing ones). MIGRATION NOTE, as the ruling requires it stated: existing deployments resolve to UTC until the root default is set — every pre-existing row reads null in both columns, null on `sys_organization.timezone` means UTC, and null on `sys_business_unit.timezone` means inherit (parent chain, then the organization, then UTC), so a deployment computes every date boundary in UTC after upgrading exactly as it did before, until an administrator sets `sys_organization.timezone`. -->
9+
10+
Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238.
11+
12+
**The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC.
13+
14+
**What lands.**
15+
16+
- `sys_business_unit.timezone``text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`.
17+
- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`.
18+
- plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry.
19+
20+
**The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written.
21+
22+
**Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned.
23+
24+
**One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately.
25+
26+
**Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question.

content/docs/protocol/kernel/error-handling.mdx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,25 +301,30 @@ deriving it from this page.
301301

302302
#### `INVALID_FIELD`
303303
**HTTP Status:** 400
304-
**Meaning:** Field value has wrong type
304+
**Meaning:** A field name in the request does not exist on the target object — a name that
305+
resolves to nothing, not a value of the wrong type. On a list read it also covers an
306+
unreserved query parameter, which `GET /data/:object` reads as a field filter.
305307

306308
**Example:**
307309
```json
308310
{
309311
"success": false,
310312
"error": {
311313
"code": "INVALID_FIELD",
312-
"message": "Field 'age' must be a number",
314+
"message": "Unknown field 'age' on object 'contact'",
313315
"details": {
314316
"field": "age",
315-
"expected_type": "number",
316-
"actual_type": "string",
317-
"value": "twenty-five"
317+
"object": "contact"
318318
}
319319
}
320320
}
321321
```
322322

323+
The [error catalog's `INVALID_FIELD` entry](/docs/api/error-catalog#invalid_field) carries
324+
the authoritative cause text — it enumerates every read axis this one code answers on
325+
(`select`, `expand`, `searchFields`, `groupBy`, `aggregations[].field`) and the
326+
off-request `backfillSummaryNulls` case.
327+
323328
### Resource Errors
324329

325330
#### `RESOURCE_NOT_FOUND`

content/docs/references/shared/expression.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ envelope.
2121
| dialect | engine | use |
2222
|:---|:---|:---|
2323
| `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values |
24-
| `cron` | `cron-parser` | job schedules |
24+
| `cron` | none at parse time — `croner` fires it at schedule time, on the one wired slot | job schedules |
2525
| `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates |
2626

27+
No cron syntax is judged at parse time: `croner` evaluates a cron slot only
28+
when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule`
29+
`CronJobAdapter`, where an invalid pattern is refused); every other
30+
cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s
31+
registered `cron` engine has no caller outside that package.
32+
2733
Those three are the whole list — it is exactly the `ExpressionDialect` enum
2834
below. Procedural JavaScript is **not** a dialect: it is the L2 authoring
2935
surface, the sandboxed, capability-gated `ScriptBody { language: 'js' }` in

packages/cli/src/commands/diff.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
printSuccess,
99
printWarning,
1010
printError,
11+
printErrorToStderr,
1112
printInfo,
1213
printStep,
1314
createTimer,
@@ -177,11 +178,32 @@ export default class Diff extends Command {
177178
const beforePath: string | undefined = args.before || flags.before;
178179
const afterPath: string | undefined = args.after || flags.after;
179180

181+
// This refusal goes to STDERR, and it is the one write in this file that
182+
// has to (#15697).
183+
//
184+
// It sits ABOVE the first `if (!flags.json)` below, so it is reached with
185+
// the face still undecided and fires in BOTH — the text face and the
186+
// machine face alike. Measured on the published entry `bin/run.js` with
187+
// `NO_COLOR=1` and the streams captured separately, it used to answer
188+
// `os diff --json` with **exit 1, 141 bytes of prose on stdout and an empty
189+
// stderr**: `JSON.parse(stdout)` threw, on the one stream `--json` reserves
190+
// for the machine (`utils/json-stdout.ts`). Both faces measured identically,
191+
// because there is no branch here to tell them apart.
192+
//
193+
// ⚠️ Every other diagnostic in this file stays on stdout deliberately: they
194+
// sit INSIDE a `!flags.json` branch, i.e. the command has already decided it
195+
// is rendering its text face, which is exactly the case `printError` is for
196+
// (see the note on {@link printErrorToStderr}).
197+
//
198+
// ⛔ Moving the bytes is the whole change. The exit code stays 1, the
199+
// wording stays identical, and no payload is invented: what `--json` should
200+
// emit on a refusal is an open envelope question (#15549) touching this
201+
// command family at once, and settling it is above this fix's authority.
180202
if (!beforePath || !afterPath) {
181-
printError('Two config file paths are required.');
182-
console.log('');
183-
console.log(chalk.dim(' Usage: objectstack diff <before> <after>'));
184-
console.log(chalk.dim(' or: objectstack diff --before path1 --after path2'));
203+
printErrorToStderr('Two config file paths are required.');
204+
console.error('');
205+
console.error(chalk.dim(' Usage: objectstack diff <before> <after>'));
206+
console.error(chalk.dim(' or: objectstack diff --before path1 --after path2'));
185207
process.exit(1);
186208
}
187209

0 commit comments

Comments
 (0)