Skip to content

Commit bffebcf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16025-hono-auth-mount-basepath
2 parents 94a19ec + de75e40 commit bffebcf

27 files changed

Lines changed: 4425 additions & 145 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`flow-decision-unconditional-branch` now reports the decision that gates on nothing — the shape the rule used to skip.
6+
7+
A `decision` whose out-edges carry no `condition` and no `isDefault`, and whose node declares no `config.conditions[]`, selects no branch at all: the automation engine's own decision executor reports no branch when `conditions[]` is empty, so traversal considers every out-edge and each successor runs on every pass. The gateway is decoration. The rule could not see that shape, because it was framed as "an unconditional edge undercuts a guarded one" and read zero guarded edges as nothing to undercut — so the strictly worse gateway was the one case that stayed silent, and it is the harder one to notice in review, because the node still says `type: 'decision'`.
8+
9+
Same rule id, same `warning` tier, with its own message: it names the out-edges that run unconditionally and offers the three fixes (a `condition` per branch plus `isDefault: true` on the fallback, a `config.conditions[]` whose `label` matches an out-edge, or dropping `type: 'decision'` for the node the gateway already behaves as). The mixed shape — one guarded out-edge beside an unconditional one — keeps its existing wording and its single finding.
10+
11+
Decisions that do declare their routing stay silent, including the two that are easiest to catch by mistake: an ordinary gateway with guarded edges, and a decision that routes by `config.conditions[]` labels alone with bare out-edges. A decision declaring a label no out-edge claims remains the gating `flow-branch-label-unmatched` on its own, with no second finding piled on the same node.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os generate migration` now emits the character column `driver-sql` actually creates, in both the TypeScript and the SQL format.
6+
7+
A `text` field took `VARCHAR(255)` from both generators while the platform creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. Enumerating the whole character-column family found the same disagreement in eight more places: the SQL format gave `url` and `phone` and `color` widths nothing on the platform has (2048, 50 and 7 against the platform's 255), and neither format read a field's declared `maxLength` at all, so a `maxLength: 400` email was `varchar(400)` on the platform and `varchar(255)` in the migration generated for it.
8+
9+
All of them now follow the platform's own three answers: the text family is unbounded unless the object KEYS the column — a field declared `unique`, or one an object-level `indexes[]` entry lists, takes `varchar(maxLength)` up to the 768-character key-part ceiling, exactly as the platform builds it, and stays unbounded above that ceiling or with no declared bound, where the declared bound is enforced at the write seam instead — the string family takes its declared `maxLength` verbatim in both directions, and TEXT rather than a clamp when it exceeds what a `varchar` can express, and the remaining string-valued types keep the default width and ignore a declaration, because their stored value is an option code or another row's id rather than the declared string.
10+
11+
The keyed half was measured after the rest: `{ type: 'text', unique: true, maxLength: 100 }` built `varchar(100)` on the platform and `text` in both generated tables, so a 300-character value the platform REFUSES was accepted by every generated table — the same disagreement as the headline row, pointing the other way.
12+
13+
This scopes to PostgreSQL, which is the only dialect `os generate migration --format sql` claims.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
A report's `dataset`, `rows`, `columns` and `values` are checked whether or not the report draws a chart (#16105)
6+
7+
**Behaviour change — new findings on reports that used to publish clean.** `validateChartBindings` reached a report through one closure that opened `if (!isRec(chart)) return`, and that closure was the only place a report's `dataset` was ever passed to the resolver. Two gaps followed, and both are closed:
8+
9+
- **A report authored without a `chart` was not checked at all.** Bind it to a dataset that does not exist and `os lint` exited 0 and named nothing. It now reports `chart-dataset-unknown` at `error`, the same as a charted report always did.
10+
- **`rows` and `columns` were resolved on no report, charted or not.** On one and the same report object the measure selection (`values`) was resolved against the dataset and the dimension selection beside it was not. Both now report `chart-dimension-unknown` at `error` for a name the bound dataset does not declare as a dimension, at `reports[i].rows[j]` / `reports[i].columns[j]`. A chartless report's `values` is resolved for the first time too, under the existing `chart-measure-unknown`.
11+
12+
`ReportSchema` is what makes these bindings rather than free text: it requires `dataset` + `values` on every non-`joined` report, and declares `rows` (the down axis) and `columns` (the across axis a `matrix` pivots on, ADR-0021 D2) as dimension names taken from that dataset. The chart is optional decoration on top of a binding the report already has. So a report bound to a missing dataset, or grouping on a dimension its dataset does not declare, now fails authoring instead of rendering blank or mis-grouped in production.
13+
14+
No new rule id, no severity moved, and the charted path is unchanged — `chart-axis-not-selected` stays a `warning` and still resolves against the chart's own `chart.yAxis`. Two smaller corrections come with the restructure, both on messages an author reads:
15+
16+
- The dataset finding on a report now points at `reports[i].dataset`, the key the author wrote. It used to say `reports[i].chart.dataset`, a position a report does not have.
17+
- Its sentence ends "there is no data to render" rather than "the chart has no data to render", which is not true of a report that draws no chart.
18+
19+
Blocks of a `joined` report carry the same keys and take the same checks. An unresolvable dataset is still exactly one finding per report or block.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall"
6+
7+
Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability **probe**: every step of its fallback chain absorbs every rejection and answers `undefined`. So a `tenancy` service that was registered and **failed to build** arrived at both gates as the same value a deployment that never registered one produces, and both read that as "there is no wall". Measured on the pre-fix tree against a real kernel whose `tenancy` is registered through a throwing factory: `POST /keys` answered **201** and minted an organization-less key, echoing the raw secret once, where a walled posture refuses one; and an organization administrator's install-wide activation write answered **200** and wrote the row, where ADR-0126 §5 requires the platform operator.
8+
9+
The identity step already read this fact through the classified lookup, so one deployment held two readings of its own wall question at once — 503 at the identity step, admitted at the door bodies these gates guard. The gates now read the same classification, taken from the registry's own brand and never from message text: a service that was **never registered** stays quiet and behaves exactly as before (an org-less key is still minted, and a single-organization deployment's own admin can still flip an install-wide switch — with no tenancy service, install-level and org-level are one scope under ADR-0093 D4/D5), while a service that is **registered and unable to answer** raises `AuthzStoreUnavailableError` — 503 `SERVICE_UNAVAILABLE` — instead of degrading to "no posture". Nothing is minted and nothing is permitted on a posture that was never read. The activation gate is one body behind **two** routes, so three routes change: `POST /keys`, `POST /actions/_activation/:object/:action` and `POST /automation/:name/toggle`. Every gate reads the posture in the request's own environment scope, as the identity step does, so a `tenancy` registered `ServiceLifecycle.SCOPED` is resolved rather than reported as an outage.
10+
11+
`resolveService` keeps its probe contract for every other name and every other domain: the classified read is a second, opted-into member — `DomainHandlerDeps.resolveServiceOrLoud` — that a gate calls one site at a time, so no gate outside the three routes above changes behaviour. **Minor** rather than patch: this grows the exported `DomainHandlerDeps` interface with a required member, which is a published-surface addition — the same shape the three `DomainHandlerDeps` growths in 17.0.0 shipped as minor changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
A wizard screen is no longer skipped after a durable pause because a record column happens to be an array.
6+
7+
`judgeHeadlessScreen` decides a screen was already answered by proving the negative: a field is **not** caller-supplied when the subject record carries that key and `params` holds the same value — necessary because the params bag a flow action arrives with is `{ ...record, recordId, <object>Id, ...params }`, so every column of the launched row is in there whether the caller named it or not.
8+
9+
That comparison was reference identity (`Object.is`), which is real in memory and does not survive persistence. A suspended run stores its context as JSON and resumes from the parsed copy — and the store is preferred over the in-process cache whenever one is wired, so no restart is needed. After that round trip an **array or object** column is equal but no longer identical: the record leg could not disprove it, the field read as caller-supplied, and a later screen with no required fields of its own was **skipped on a run that had supplied nothing**. An interactive user pressed a button and never saw a form they should have been shown; the run completed carrying the row's own value as if they had typed it. Reproduced end to end against a wired store, not inferred.
10+
11+
The record leg now compares by value (`isDeepStrictEqual`), which survives serialisation. That predicate compares primitives with `Object.is` itself, so this is a strict widening of the "not caller-supplied" set — every pair the old check called equal it still calls equal, plus the structurally identical non-primitives. More screens render, never fewer, which is the direction this module resolves every ambiguity in.
12+
13+
**Accepted cost, precisely.** A caller that genuinely re-sends a value structurally identical to the row's column is no longer distinguishable from the dispatcher's seed, so it now gets the screen rendered instead of skipped — a lost skip on a headless call, never a lost run, and the same trade the module's other legs already make. Scalar columns behave exactly as before, on both sides of a pause. The row-id leg keeps identity comparison deliberately: a row id is a scalar by construction, so serialisation cannot defeat it and there is nothing there to widen. Measured overhead is a deep compare per declared screen field at screen entry: ~1.5 µs added for a deliberately maximal screen that declares a field for every one of a ten-column row, which is about 38% of one `JSON.stringify` of the run context — a cost the durable store already pays on every suspend.
14+
15+
This closes the gap the same release's screen-flow headless-satisfaction note records as known.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
A restored suspension now carries the state the run was paused with, including nested values.
6+
7+
`restoreConsumedSuspension` is the operator exit from a run whose resume consumed the pause and then failed downstream: it puts the suspension back so the run is resumable again. What it put back was documented as the pause "verbatim", and was — for the top-level variables only.
8+
9+
The flow scope a resume hands the downstream nodes was rebuilt as `new Map(Object.entries(run.variables))`: that copies the keys and shares every value object with the parked snapshot. An executor that keeps state in the scope and updates it **in place**`map` tracks its progress in `<nodeId>.$mapState` — therefore wrote straight through into the snapshot, and the journal recorded the result as the pause. An operator repairing a stranded `map` run got a snapshot claiming progress made by the attempt that failed, not the progress the run actually had when it paused.
10+
11+
Measured, not inferred: the durable row held `started: 1` at the pause and the restore put back `started: 99`.
12+
13+
The pause's variables are now copied before the failed attempt runs, on the line that already captures the pause's step count for the same reason. No later placement works — the node mutates and then throws, so a copy taken when the journal is written copies the mutation. Nothing else changes: the running flow still sees exactly the scope it saw before, the resume ordering is untouched, and a value that cannot be copied falls back to the previous behaviour with a warning rather than costing the operator the repair.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**.
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` |
167-
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:157`, `:211` |
167+
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:177`, `:268` |
168168
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
169169
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
170170
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` |

packages/cli/src/commands/generate-builtin-id-column.pin.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,13 @@ describe('the builtin id column both migration generators emit (#15040)', () =>
226226
expect(sql.indexOf('"id"')).toBeLessThan(sql.indexOf('"title"'));
227227
const ts = generateMigrationTs(CONFIG as Record<string, unknown>);
228228
expect(ts).toContain("await db.schema.createTable('account'");
229-
expect(ts.indexOf("table.string('id')")).toBeLessThan(ts.indexOf("table.string('title')"));
229+
// #16091 — matched on the field NAME rather than on its column method. The
230+
// assertion is about ORDER (the primary key comes first), and a reader keyed
231+
// to `table.string` silently became `indexOf(…) === -1` the moment `title`,
232+
// a `text` field, moved to `table.text` — which reads as a passing
233+
// "less than" only until you notice what it is less than.
234+
expect(ts.indexOf("table.string('id')")).toBeLessThan(ts.indexOf("('title')"));
235+
expect(ts.indexOf("('title')"), 'the title column vanished from the output').toBeGreaterThan(0);
230236
// Each generator carries exactly ONE hardcoded id line — the shape that let
231237
// these two disagree with the driver in the first place, and the reason a
232238
// fix to one of them can silently leave the other behind. Counted over the

packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,18 @@ describe('#14828 — the SQL answers are the platform’s, not this file’s inv
567567
expect(tsInterfaceType('number')).toBe('number');
568568
// The driver's own answer for the headline member, read where it lives.
569569
expect(createColumnArm('autonumber')).toContain('table.string(name)');
570-
expect(sqlColumn('autonumber')).toBe(sqlColumn('text'));
570+
// #16091 — compared against `lookup`, not against `text`. Both were
571+
// `VARCHAR(255)` when this line was written, which made `text` a usable
572+
// stand-in for "the driver's default string column"; it is not one any
573+
// more. `createColumn` gives `text` its text-family arm (an unbounded TEXT
574+
// for every unkeyed column) and gives `lookup` the same bare
575+
// `table.string(name)` it gives `autonumber` — asserted here, from the
576+
// driver, so the comparator cannot silently become a different question again.
577+
expect(createColumnArm('lookup')).toContain('table.string(name)');
578+
expect(sqlColumn('autonumber')).toBe(sqlColumn('lookup'));
579+
// Anti-vacuity: the comparator is a real, DIFFERENT answer from the
580+
// text family's, so this equality is a measurement rather than a tautology.
581+
expect(sqlColumn('autonumber')).not.toBe(sqlColumn('text'));
571582
expect(tsColumn('autonumber')).toBe("table.string('f_autonumber')");
572583
});
573584

packages/cli/src/commands/generate-multiple-json-column.pin.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,14 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces',
165165
// control cannot be satisfied by one column shape for everything either.
166166
expect(sqlColumn('single_lookup')).toBe('VARCHAR(255)');
167167
expect(tsColumn('single_lookup')).toBe("table.string('single_lookup')");
168-
expect(sqlColumn('single_text')).toBe('VARCHAR(255)');
169-
expect(tsColumn('single_text')).toBe("table.string('single_text')");
168+
// #16091 — `text` is an unbounded TEXT column now, which is what
169+
// `createColumn`'s text-family arm builds for every unkeyed column. The
170+
// control is unweakened by that for exactly the reason the `lookup` note
171+
// above gives: what it discriminates is scalar-vs-JSON, and TEXT is scalar.
172+
expect(sqlColumn('single_text')).toBe('TEXT');
173+
expect(tsColumn('single_text')).toBe("table.text('single_text')");
174+
// …and it still discriminates: the scalar answer is not the JSON one.
175+
expect(sqlColumn('single_text')).not.toBe(sqlColumn('multi_text'));
170176
expect(sqlColumn('single_file')).toBe('VARCHAR(2048)');
171177
expect(tsInterfaceType('single_lookup')).toBe('string');
172178
});
@@ -285,8 +291,8 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces',
285291
// this card does NOT touch is present in both outputs. Without it, "does
286292
// not contain" would pass on an empty string.
287293
expect(other).toContain('CREATE TABLE IF NOT EXISTS "probe" (');
288-
expect(other).toContain('"t" VARCHAR(255)');
289-
expect(otherTs).toContain("table.string('t')");
294+
expect(other).toContain('"t" TEXT');
295+
expect(otherTs).toContain("table.text('t')");
290296

291297
// A RENDERED string (prefix + counter + suffix), never an integer sequence.
292298
expect(other).toContain('"a" VARCHAR(255)');

0 commit comments

Comments
 (0)