Skip to content

Commit 9b47f88

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15900-tenancy-posture-loud-resolve
2 parents c34cae6 + 455d037 commit 9b47f88

12 files changed

Lines changed: 763 additions & 29 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the audit binder stamps `created_at` from the system clock on an ordinary create, so a caller-supplied value no longer survives a plain `POST` (#15964)
6+
7+
The `beforeInsert` audit stamp was `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. Since the static-`readonly` strip moved INSIDE `engine.insert` (2026-09-03 ruling, option C) it runs AFTER the before-phase hooks, and its guard treats a key a `beforeInsert` hook ASSIGNED as the hook's write rather than a caller forgery. The `??` therefore laundered the caller's bytes past that strip: a normal authenticated `POST /api/v1/data/OBJECT` carrying `created_at: '1999-01-01T00:00:00.000Z'` stored exactly that on an object declaring `created_at` as `readonly: true`, while `id`, `updated_at` and every other author-declared readonly datetime in the same payload were taken. `created_at` is the audit anchor, so a forgeable one makes after-the-fact attribution untrustworthy.
8+
9+
The stamp now takes the same shape as `updated_at`:
10+
11+
```ts
12+
record.created_at = preserveAudit ? (record.created_at ?? now) : now;
13+
```
14+
15+
**What changes for a caller.** An ordinary create no longer preserves a supplied `created_at` — the value is overwritten with the server instant rather than deleted, so the column is still a real stamp and no `defaultValue` re-derivation is involved. This narrows the accept set to the `readonly` contract the field already documents; no exported symbol, schema or config key moves.
16+
17+
**The historical-import channel is unchanged and pinned.** `runImport({ treatAsHistorical: true })` sets `preserveAudit: true` on the write context (`@objectstack/rest`), and that branch still reinstates an original `created_at`, exactly as it has reinstated `updated_at`/`updated_by` since #3493. This is why the fix is the `preserveAudit` ternary rather than a bare `= now`. The create-side strip still does not read `preserveAudit` (2026-08-08 ruling, untouched): the preservation is the audit binder's, and it always was.
18+
19+
**A creator that back-dated rows through the old `??` must now ask for it.** Any insert path that supplied a historical `created_at` without `preserveAudit` now gets the server instant. The remedy is one context key on the write (`preserveAudit: true`), the same one `treatAsHistorical` sets.
20+
21+
Ruled by the maintainer on 2026-09-06 (decision batch #54, option A).
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
`CubeRegistry`'s documentation now describes what the class actually does. Four claims it shipped were measured false against the built package; no behaviour changes, and the corrected text ships in `dist/index.d.ts`, where consumers read it.
6+
7+
The class docblock said cubes reach the registry "from two sources: manifest definitions, and object schema inference". Neither half held. Two sources were missing — a compiled dataset's Cube (ADR-0021), registered under the dataset's name by `queryDataset`, and the ad-hoc Cube `ensureCube` / `inferCubeFromQuery` mints from the members a query references. And object schema inference is `inferFromObject`, which no path in this repository calls: its only in-tree caller is a unit test. The list now names the three sources that do write to the registry, and points at the method for the fourth door instead of advertising it as delivered.
8+
9+
`inferFromObject`'s own "heuristic rules" list was wrong in three of five bullets. Driving the built package:
10+
11+
- `number` / `currency` / `percent` fields mint one `sum` and one `avg` measure each — not the documented `sum`, `avg`, `min`, `max`. No `min` or `max` measure exists.
12+
- `boolean` fields become a `boolean` dimension and nothing else. The documented "`count` measure (count where true)" is not minted.
13+
- Every field becomes a dimension. The documented "all non-computed fields" implies an exclusion the code does not have, on a parameter that carries no such flag.
14+
15+
The two accurate bullets (a default `count` measure, and `date` / `datetime` fields becoming `time` dimensions granulated day/week/month/quarter/year) are kept and stated in the form the run produced.
16+
17+
The method's docblock now also records what it is: a published method with no in-repo caller, still callable by consumers through the package entry (`CubeRegistry`) or `AnalyticsService.cubeRegistry`, whose output does reach the wire because `getMeta()` serves its labels as `CubeMeta` titles.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
**BREAKING** `os lint --eval --generator ""` now refuses instead of quietly running the offline eval, matching the rule the same flag already follows without `--eval`.
6+
7+
Eval mode guarded the generator load with a truthiness test, so an empty string fell straight through it: the module was never loaded, no warning was printed, and the `Failed to load generator` message that exists for exactly this failure was never reached. What came out was the ordinary offline report — `Mode: offline`, `5/5 passed · mean 99/100`, exit 0 — to someone who had asked for a live run and read that score as their generator's.
8+
9+
It was not merely ineffective. Driven against the same command with the flag absent entirely, and with the elapsed-time token normalised, the two runs produced byte-identical stdout, empty stderr and the same exit code on every face the command has, `--json` included. There was no channel on which the difference was visible. The usual way to type it is `--generator "$GEN"` in a script where `GEN` is unset.
10+
11+
The guard now tests whether the flag was provided rather than whether its value is truthy — the same test `os lint --generator` outside `--eval` has used since it started refusing — so one flag has one rule for "the operator typed it". No new failure shape is introduced: an empty string is a path that names no module, so it answers through the load path an unresolvable path already answered through, with the reason on `error`, exit 1, and on `--json` a single JSON document. No error code is invented for it.
12+
13+
A scripted invocation that passed an empty `--generator` to `os lint --eval` now exits 1 with the reason, where it previously exited 0 having silently scored the bundled corpus instead. Every other invocation is untouched: `--eval --generator <module>` still loads the module and scores live output, `--eval` alone still scores the bundled corpus offline, and a plain project lint is unchanged.
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) The change narrows what one CLI flag value is accepted at invocation time. No metadata surface, stored row or spec declaration is touched, so `objectstack migrate meta` has nothing to carry and the ledger has nothing to record. -->
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
Documentation: `ApprovalService.recall`'s docblock summary line no longer claims the submitter is the only actor.
6+
7+
The block opened with "Withdraw a pending request (submitter only)" and then, three paragraphs down, stated the #3424 privileged override correctly — "The #3424 privileged override reaches a PENDING request only (#12775, maintainer ruling 2026-09-02)". Both cannot be true, and the code settles it in the paragraph's favour: `overrideAdmits` short-circuits the non-submitter guard on a `pending` request. A reader who finishes the block is not misled, but the summary line is the one an editor shows on hover and the one any single-line extraction takes.
8+
9+
The summary line now reads "Withdraw an undecided request." — status is the axis and the actor rules are left to the paragraphs that already state them correctly, the same structural move the `IApprovalService.recall` docstring makes on the spec side.
10+
11+
Prose only: no guard, no branch and no signature changed. It earns a changeset rather than `skip-changeset` because `@objectstack/plugin-approvals` publishes `dist/`, and this text ships inside the published `dist/index.d.ts` for `ApprovalService.recall`.

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -852,11 +852,19 @@ export const ProjectClosureFlow = defineFlow({
852852
* variable scope, and the body sends a reminder. A hard `maxIterations` guard
853853
* keeps iteration bounded. The loop node's ordinary out-edge (`→ end`) is the
854854
* after-loop continuation — the DAG invariant for ordinary edges is preserved.
855+
*
856+
* The body also demonstrates **per-iteration containment**: the reminder runs
857+
* inside a `try_catch` whose `catch` is one bare `assignment`. A `loop` body has
858+
* no error handling of its own, so without that guard the first task with an
859+
* empty `owner` would end the whole run — later tasks never reminded, and the
860+
* run summary reporting `acted: 0` for work that did happen. With it, every
861+
* iteration is attempted and the sweep completes. See
862+
* content/docs/automation/flows.mdx §"Per-iteration containment".
855863
*/
856864
export const BatchRemindersFlow = defineFlow({
857865
name: 'showcase_batch_reminders',
858866
label: 'Batch Task Reminders (Loop)',
859-
description: 'Iterates a collection of tasks and sends a reminder for each (structured loop container, ADR-0031).',
867+
description: 'Iterates a collection of tasks and sends a reminder for each, each iteration contained by a try_catch so one bad task cannot end the sweep (structured loop container, ADR-0031).',
860868
type: 'autolaunched',
861869
variables: [
862870
{ name: 'tasks', type: 'list', isInput: true, isOutput: false },
@@ -874,15 +882,48 @@ export const BatchRemindersFlow = defineFlow({
874882
maxIterations: 500,
875883
body: {
876884
nodes: [
885+
// Per-iteration containment (#13681 / #14394) — a `loop` body has NO
886+
// error handling of its own: the container iterates with a bare
887+
// `await`, so a body node that returns `success: false` propagates
888+
// straight out and ends the WHOLE run. `notify` fails on an empty
889+
// resolved recipient set, so one task with a blank `owner` would
890+
// leave every later task unreminded while the run summary reports
891+
// `acted: 0` for work that did happen. The guard is a `try_catch`
892+
// INSIDE the body, one per iteration.
877893
{
878-
id: 'send_reminder',
879-
type: 'notify',
880-
label: 'Send Reminder',
894+
id: 'guard_reminder',
895+
type: 'try_catch',
896+
label: 'Guarded iteration',
881897
config: {
882-
recipients: '{task.owner}',
883-
title: 'Reminder ({taskIndex}): {task.title}',
884-
sourceObject: 'showcase_task',
885-
sourceId: '{task.id}',
898+
try: {
899+
nodes: [
900+
{
901+
id: 'send_reminder',
902+
type: 'notify',
903+
label: 'Send Reminder',
904+
config: {
905+
recipients: '{task.owner}',
906+
title: 'Reminder ({taskIndex}): {task.title}',
907+
sourceObject: 'showcase_task',
908+
sourceId: '{task.id}',
909+
},
910+
},
911+
],
912+
edges: [],
913+
},
914+
// The shortest handler that works: ONE bare `assignment` node
915+
// with no `config` at all. A `catch` region cannot be empty —
916+
// `FlowRegionSchema.nodes` is `.min(1)`, so `catch: {}` and
917+
// `catch: { nodes: [] }` are both refused by the parse, and
918+
// omitting `catch` entirely parses while containing NOTHING.
919+
// `edges` omitted; `errorVariable` omitted (defaults to
920+
// `$error`). See content/docs/automation/flows.mdx
921+
// §"Per-iteration containment".
922+
catch: {
923+
nodes: [
924+
{ id: 'reminder_failed', type: 'assignment', label: 'Reminder Failed (contained)' },
925+
],
926+
},
886927
},
887928
},
888929
],

packages/cli/src/commands/lint.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,37 @@ export default class Lint extends Command {
891891
private async runEval(flags: any, timer: ReturnType<typeof createTimer>): Promise<void> {
892892
let generate: ((prompt: string, id: string) => unknown | Promise<unknown>) | undefined;
893893

894-
if (flags.generator) {
894+
// [#16161] `!== undefined`, not truthiness — the SAME test the
895+
// `--generator` precondition guard in `run()` above uses, so one flag has
896+
// one rule for "the operator typed it".
897+
//
898+
// Driven on this entry before this change, from the probe project below,
899+
// with a generator that writes a marker file at TOP-LEVEL evaluation:
900+
//
901+
// os lint --eval --generator "" exit 0 · Mode: offline · 5/5 passed · marker ABSENT
902+
// os lint --eval exit 0 · Mode: offline · 5/5 passed · marker ABSENT
903+
//
904+
// Normalise the elapsed-time token and those two stdouts were BYTE-IDENTICAL
905+
// (one sha256 across both entries, `bin/run-dev.js` and `bin/run.js`);
906+
// stderr was 0 bytes in all four runs and the `--json` face differed only in
907+
// `duration`. So the empty string was not merely ineffective — it was
908+
// indistinguishable from not passing the flag, on every channel this command
909+
// has, while the report said `Mode: offline` to an operator who had asked for
910+
// a live run. The classic way to type it is `--generator "$GEN"` with `GEN`
911+
// unset in a script.
912+
//
913+
// ⛔ The opposite rule — empty means "not passed" — is not open here. #15550
914+
// settled it for the non-eval side one guard up, and the two sides read one
915+
// flag; splitting them would put two spellings of `--generator` under two
916+
// rules. Reversing it is a decision, not a patch.
917+
//
918+
// ⛔ No new refusal shape is invented for the empty case. Once the load is
919+
// attempted, an unresolvable path answers the way an unresolvable path
920+
// already answers here — the `catch` below, exit 1, `Failed to load
921+
// generator ""` on both faces. That is the same envelope
922+
// `--generator ./does-not-exist.mjs --eval` has answered with all along; an
923+
// empty string is a path that names no module, not a separate error class.
924+
if (flags.generator !== undefined) {
895925
try {
896926
const { mod } = await bundleRequire({
897927
filepath: flags.generator,

0 commit comments

Comments
 (0)