Skip to content

Commit fb0a442

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-9567-flow-conversion-conflict-ledger
2 parents 57ac54b + 7604fcc commit fb0a442

46 files changed

Lines changed: 5566 additions & 425 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
`POST /api/v1/actions/:object/:action` answers the flow-dispatch status table instead of one blanket `400 FLOW_FAILED` (#9446).
6+
7+
**What a caller sees differently.** A `type: 'flow'` action whose dispatch is REFUSED no longer reports a failed run. Three answers changed:
8+
9+
| the flow behind the action | before | now |
10+
|---|---|---|
11+
| is not registered | `400` `FLOW_FAILED` | `404` `RESOURCE_NOT_FOUND` |
12+
| is switched off | `400` `FLOW_FAILED` | `409` `FLOW_DISABLED` |
13+
| has no `start` node | `400` `FLOW_FAILED` | `422` `FLOW_NO_START_NODE` |
14+
| ran and was rejected | `400` `FLOW_FAILED` | `400` `FLOW_FAILED` (unchanged) |
15+
16+
These are the same four rows `POST /api/v1/automation/:name/trigger` has answered since #9378 + #9415, and they now come from one shared definition both doors read, so the two cannot drift apart again.
17+
18+
**Behaviourally breaking for a caller that branches on the status or the code.** Every one of these was a `400` before, so a caller treating `400` as "the run failed" was being told something false in three of the four cases: nothing had dispatched and no node had executed. A client that lumps all four together keeps working — they are all still refusals, all still `success: false` with no inner envelope — but one that reports "the flow failed" on a `400` should now distinguish. **Retry semantics differ per row**, which is the practical reason to: `409 FLOW_DISABLED` is reversible operational state (enable the flow and the identical request succeeds), while `404` and `422 FLOW_NO_START_NODE` are authoring defects that no retry fixes. `400 FLOW_FAILED` remains terminal, exactly as the console already treats it.
19+
20+
**Unchanged on purpose.** A successful run still answers `200` with the single `data` wrap (#3962). The `400 FLOW_FAILED` message keeps its existing wording (`Flow '<target>' failed: …`), which names the flow the action dispatches — the trigger route's URL carries that name and this route's does not. A `success: false` result the automation engine did not classify still refuses with `400 FLOW_FAILED` rather than falling back to `200 {success:true,data:{success:false}}` — the double envelope #3962 removed from this route.
21+
22+
**Not in scope.** Declared endpoints (`type: 'flow'` endpoints, `endpoint-executor.ts`) still answer `200` for every outcome. That door converges in its own change (#9462), where the envelope flip is a breaking change for consumers of the current double envelope and is sequenced against them.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): boot schema-sync's MySQL widening ALTER bounds its metadata-lock wait too — a blocked boot warns and carries on instead of hanging for a year (#9542)
6+
7+
#9354 bounded `lock_wait_timeout` to 120s on the widening `ALTER TABLE … MODIFY
8+
COLUMN` and made a blocked `os migrate apply` refuse loudly — but only while
9+
`flushDeferredSchemaDdl` was running. The same two widenings
10+
(`migrateMysqlDatetimeColumns` / `migrateMysqlTimeColumns`, #3942 / #3994) are
11+
reached from **boot schema-sync** through the same `initObjects` lines, and on
12+
that path the `runWideningAlters` seam returned early: the ALTER ran through the
13+
pool inheriting MySQL's own default `lock_wait_timeout` of **31,536,000 seconds
14+
— one year**.
15+
16+
So a single other session holding a metadata lock on the table parked boot at
17+
schema-sync for that long, printing nothing — indistinguishable from a crash.
18+
The widening's own `logger.warn` could not help, because it lives in a `catch`
19+
and an ALTER that never returns is never caught.
20+
21+
The bound is now armed **unconditionally** in that seam. What stays gated on the
22+
flush is the **refusal**, and only it: boot still swallows. Correctness never
23+
depends on the widening having run and a migration must never take boot down, so
24+
throwing there would trade a silent hang for a failed boot — a different answer,
25+
not the same one.
26+
27+
**What changes for a deployment.** On MySQL, a boot whose widening ALTER is
28+
blocked on a metadata lock now waits at most 120s, then logs
29+
`[sql-driver] could not widen MySQL datetime columns on …` (or its `TIME(3)`
30+
twin) naming the table, with the server's own `Lock wait timeout exceeded` as
31+
the `error` field — and boot carries on. The widening is idempotent, so the
32+
first boot after the blocker is gone completes it. Nothing changes on any other
33+
dialect, on an ALTER that is not blocked, or for `os migrate apply`, which keeps
34+
#9354's `DATABASE_ERROR` / 500 refusal.
35+
36+
120s is #9354's number, kept for boot deliberately rather than lengthened: the
37+
reasoning behind it is about how long a legitimate metadata-lock holder can
38+
plausibly hold the lock, which is a property of the lock and not of who is
39+
waiting on it. Boot's difference from the flush is what happens when the bound
40+
fires, never how long it waits. No retry logic and no configurability — the
41+
2026-08-17 ruling's minimality, unchanged.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(tooling): `check:dispatcher-error-vocabulary` reports lowercase codes and the two stamp positions it could not see — a live 403 had been invisible to both vocabulary gates (#9460)
6+
7+
The gate's published bound said it scanned "only SCREAMING_SNAKE literals", and
8+
handed lowercase to `check:error-code-casing`. Half of that delegation was real
9+
and half was a hole, and the hole is where `plugin-security`'s live 403
10+
`owd_widening_forbidden` sat through two ADR-0112 sweeps: both gates read the
11+
file, both reported nothing, each leaving it to the other.
12+
13+
**The card's premise was that the scan's patterns are case-sensitive. They are
14+
not** — the literal shapes already matched `[A-Za-z]`. Two explicit filters
15+
dropped the value after the match, and the producer was invisible for a
16+
different reason entirely, so the prescribed one-line widening would not have
17+
found it. Measured before changing anything: reporting every lowercase stamp
18+
took the scan from 12 sites to 94, and **all 82 new findings were D6/D6b/D6c
19+
neighbours or Zod's own issue codes** — it would have called
20+
`ctx.addIssue({ code: 'custom' })` an unregistered ObjectStack error code.
21+
22+
So lowercase is now reported **except** in the two positions where
23+
`check:error-code-casing` reads the identical characters (`code: 'x'`,
24+
`.code = 'x'`). There the delegation is genuine: that gate carries the
25+
D6/D6b/D6c discrimination this one does not have. Everywhere else — a constant,
26+
a template, a helper parameter — there is no quoted literal at the stamp site
27+
for a `code`-anchored pattern to match, that gate is structurally blind, and
28+
dropping the value reported it to nobody. What is measured is now "outside the
29+
vocabulary **and** unowned by the gate we delegate lowercase to", never "is it
30+
SCREAMING_SNAKE".
31+
32+
Three stamp positions the scan could not see, all of them widenings:
33+
34+
- **`codehelper`** — a file declares one factory and throws through it
35+
everywhere (`postureError(code, message)`, `makeError(status, code, message)`,
36+
a `constructor(code, message)`). The stamp `(err as any).code = code` knows
37+
the token `code` but not the value; the call site knows the value and never
38+
writes the token. Every pattern in **both** gates anchors on that token. The
39+
join is the parameter, so its **index** names the argument to read — derived,
40+
never assumed to be zero, because two live helpers put `code` second and a
41+
first-argument rule reads a number and an English sentence as error codes.
42+
- **`assignconst`** — `err.code = DENY_CODE`, the assign position's constant
43+
sibling. #9223 closed exactly this gap for object literals; the assign
44+
position kept it.
45+
- **`assign`** with a cast on the left. The old anchor demanded a bare
46+
identifier where `(err as any).code = 'X'` puts a `)`.
47+
48+
The scan goes from 12 classified sites to 18, and from 0 to 2 codes awaiting a
49+
ledger entry — the ratchet moving in the direction it exists to move.
50+
`FLOW_CONVERSION_CONFLICT` (a live 409 from the metadata write path) and
51+
`owd_widening_forbidden` are recorded as `pending-registration`; four
52+
`MigrationJournalRefusal` codes are `boot-refusal` (their only consumers are two
53+
CLI commands, no HTTP boundary). ⛔ No allowlist entry, no narrowed pattern, no
54+
raised ceiling: **registering or renaming a code stays the `packages/spec`
55+
lane's call**, and these rows record the measurement rather than prescribing the
56+
remedy.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): the flat error responder narrows a thrown `code` to the declared ADR-0112 vocabulary, demoting an unregistered spelling to `declaredCode` (#9232)
6+
7+
**If you read `error.code` off a `packages/rest` flat error body today, read this.**
8+
9+
`packages/rest` answers errors in the flat dialect — `{ error: 'message', code: 'X' }`,
10+
with `code` at the body's top level. Until now, when that body came from a *caught*
11+
error, whatever string the producer had put on `.code` was copied to the wire verbatim,
12+
including spellings that are not members of the ADR-0112 error vocabulary
13+
(`StandardErrorCode` plus the registered ledger). Every other HTTP door in the platform
14+
had already stopped doing that.
15+
16+
It stops here too. A thrown `code` is now resolved exactly as the dispatcher door
17+
resolves it, by the same shared `resolveThrownHttpError` / `demotedDeclaredCode` pair:
18+
19+
- **A registered code is unchanged.** It still arrives in `code`, verbatim, with nothing
20+
added beside it. If your branches read registered codes — and every consumer branch
21+
measured in this repo, the SDK and the console does — nothing about your code changes.
22+
- **An unregistered code is demoted.** `code` now carries the vocabulary member the HTTP
23+
status derives (a 403 gives `PERMISSION_DENIED`, a 409 `RESOURCE_CONFLICT`, and so on),
24+
and the producer's own spelling moves, unchanged, to a new top-level `declaredCode`
25+
field beside it. Nothing is lost — but a branch written against an *unregistered*
26+
spelling in `code` will stop matching, and must read `declaredCode` instead.
27+
Presence of `declaredCode` means demotion: it is absent whenever the producer's code
28+
was recognised.
29+
- **A throw that declared no code still carries none.** Narrowing the vocabulary does not
30+
start inventing codes for bodies that had none.
31+
- **A non-string `code` no longer reaches the body at all.** A numeric driver errno could
32+
previously land in `code`; it was never a legal value there and is now treated as
33+
context, as it already was at every other door.
34+
35+
The observable case in this repo: the object-posture gate's `403 owd_widening_forbidden`
36+
now answers `{ code: 'PERMISSION_DENIED', declaredCode: 'owd_widening_forbidden' }`. That
37+
body could not previously satisfy the schema it claimed to satisfy.
38+
39+
The error body's **position** is unchanged — this dialect still puts `code` at the top
40+
level rather than in `error.code`. Converging the position is a separate, still-open line
41+
held by the `check:route-envelope` ratchet, and was explicitly not a precondition here.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
test(cli): `os serve`'s unknown-hostname guard gets a test seam — the middleware, refusal included, is now reachable without booting a server (#9442)
6+
7+
The `OS_ROOT_DOMAIN` guard was a plugin object literal built inside
8+
`Serve.run()`, closing over its locals and installing itself on a `http.server`
9+
service resolved from the plugin context. Nothing about it was exported or
10+
constructible, so every branch — the health/readiness bypass whose own comment
11+
says a 404 there "would kill the container", the reserved-subdomain and
12+
`/_console` redirect branches, the `/_admin` and `/.well-known` pass-throughs,
13+
the lazy env-registry read whose every failure mode falls through — had zero
14+
regression coverage.
15+
16+
It is now `createUnknownHostnameGuardPlugin()`, exported from `serve.ts` the way
17+
its sibling helpers are, with `run()` calling it. Behaviour is unchanged: same
18+
branches in the same order, same bodies, and `OS_CLOUD_URL` is still read per
19+
request rather than captured at install time. What is new is a suite that mounts
20+
the real middleware on a real Hono app and pins BOTH directions — every bypass
21+
as an explicit pass-through, and the refusal by `error.code` **and** HTTP status
22+
together.

0 commit comments

Comments
 (0)