Skip to content

Commit fecb862

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/merge-14036-round2
2 parents b0db873 + a7002ce commit fecb862

152 files changed

Lines changed: 9611 additions & 661 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(plugin-approvals): the stranded-request inspection now sees a run that FAILED mid-resume (#13909)
6+
7+
`ApprovalService.inspectStrandedRequests` was structurally blind to the shape
8+
#13909 owns, and reported `0` for it — the one shape an operator most needs to
9+
see.
10+
11+
**The mechanism.** `AutomationEngine.resumeInternal` consumes the suspension
12+
*before* running the downstream nodes: `forgetSuspendedRun(run, 'resumed')`
13+
precedes `traverseNext`. A downstream node that merely THREW therefore threw
14+
with the pause already gone, the catch arm recorded the run `failed`, and
15+
nothing can resume it again (`resume` answers `RUN_NOT_FOUND`, `cancelRun` is a
16+
no-op). The decision is durable and the flow stopped half-way.
17+
18+
**Why the inspection could not see it.** Its second oracle was
19+
`if (terminal) continue` — the existence of ANY run-history row ended the check,
20+
on the reading "the run ran to a terminal state, it is not dangling". But the
21+
terminal row here is written BY the failure that stranded the request, so the
22+
evidence of the defect was read as evidence of health. `releaseDeadRunRequests`
23+
cannot see it either: it scans `status: 'pending'`, and the decision is what
24+
took the row out of `pending`.
25+
26+
**The widening, and its limits.** The second oracle now classifies the run
27+
instead of merely detecting it. A `failed` run is reported; `completed`,
28+
`cancelled` and `paused` are each still skipped, one named reason at a time, and
29+
a status this code does not recognise is skipped too — the spec's
30+
`ExecutionStatus` vocabulary is wider than the four statuses the engine writes,
31+
and a future status must not become a silent false positive. `paused` in
32+
particular stays skipped because "the suspension is gone but no terminal row is
33+
written yet" is exactly what a resume IN FLIGHT looks like. The first oracle is
34+
unchanged: a run the suspension store still holds is alive, and an unreadable
35+
store is still counted `undetermined`, never condemned.
36+
37+
Reported rows now carry `runState: 'missing' | 'failed'` (new exported type
38+
`StrandedRunState`), because the two shapes need different remedies: a `missing`
39+
run has no history to read, a `failed` one has a step log and an error naming
40+
the node that threw. The sweep's own warning splits its counts the same way.
41+
`StrandedApprovalRequest` is an output-only reporting shape the service
42+
produces; the added field is not constructed by any caller in this repo.
43+
44+
**Still read-only, and still not a census.** No status is changed and no run is
45+
cancelled — the decision genuinely happened. This makes the condition *visible*
46+
in a deployment; how many runs are already in it can only be answered against
47+
that deployment's own tables. Nothing here changes the resume ordering, which
48+
is #13909's own next slice.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): correct `AutomationContext.flowName`'s attribution prose — elevation decides authorization, not attribution (#14011)
6+
7+
The published contract for `AutomationContext.flowName` (shipped in
8+
`dist/contracts/index.d.ts`) said a `runAs:'system'` run "resolves no user", so
9+
`resolveRunDataContext` labels its data operations `svc:flow:<flowName>` on
10+
`ExecutionContext.actor` "instead of leaving the audit row unattributed".
11+
12+
That reads as **"system elevation costs you the operator in the audit trail"**,
13+
and it has not been true since #5494. What ships: `resolveRunDataContext`
14+
carries the triggering user through UNCHANGED under elevation — `isSystem`
15+
alone decides authorization, while the user drives the platform's attribution
16+
stamps. A write made with `{ ...callerCtx, isSystem: true }` leaves
17+
`created_by` / `updated_by` naming the caller, identical to the same write on
18+
the plain user path; the audit writer records `session.userId ?? session.actor`
19+
on `sys_audit_log.actor`, in that order, with no `isSystem` gate anywhere in
20+
either path.
21+
22+
The `svc:flow:` labelling the sentence described is real, but it is the
23+
FALLBACK for a run that genuinely has no operator — a schedule, or a
24+
`runAs:'system'` flow fired by a write that itself carried no user. The
25+
sentence generalised it to every `runAs:'system'` run.
26+
27+
Runtime behaviour is unchanged: this corrects the description of shipped
28+
behaviour, nothing else. The correction is now also pinned by
29+
`runas-attribution-contract.test.ts` in `@objectstack/service-automation`, which
30+
asserts both limbs against the real ObjectQL stack — so if the code ever
31+
becomes what the old prose described, a test goes red rather than a reader
32+
having to re-measure.
33+
34+
Why it earned a card rather than a shrug: downstream, the stale sentence was
35+
written into an adjudication as the explicit stop-condition for a security
36+
design ("if elevation erases the operator, stop and report a fork"). The
37+
correct design was one measurement away from being abandoned on a false
38+
premise. Prose that talks a reader out of the right answer is worth more than a
39+
cosmetic fix.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os lint` refuses a hook body that silently stopped being metadata (#13651)
6+
7+
An L2 hook handler is lowered to a metadata-only `body.source` and evaluated in
8+
QuickJS with no module scope. When the handler reaches out of that scope,
9+
`extractHookBody` refuses — and `lowerCallables` caught the refusal, recorded it,
10+
and shipped the callable through the back-compat `.mjs` bundle instead. `os build`
11+
exited 0. The hook kept working locally. What changed silently was the
12+
**deployment shape**: the app had stopped being shippable as pure metadata.
13+
14+
The refusal was never the missing piece — `extractHookBody` had already computed
15+
the exact free-identifier list. What was missing is that nothing said no to the
16+
recorded array.
17+
18+
**The refusal now carries its classification — and what that publishes rides
19+
on two CLI surfaces, not on new API exports.** Internally,
20+
`HookBodyExtractionError` (with `HookBodyRefusalKind`) names which rule
21+
refused — `free-identifiers` / `forbidden-token` / `unparseable` — and
22+
`lowerCallables` records it beside the identifier list on each
23+
`bodyExtractionWarnings` entry. Those types are module-internal: the package
24+
`exports` map exposes only `.` and `./console`, and `src/index.ts` re-exports
25+
none of them. What this release actually publishes is:
26+
27+
- **`os lint`'s exit contract** — the new `hook-body/not-lowerable` rule is an
28+
`error`, so `os lint` can now exit 1 where it previously exited 0.
29+
- **`os build --json`** — each `bodyExtractionWarnings` entry now carries
30+
`kind` and `freeIdentifiers` fields beside the unchanged `origin`/`reason`.
31+
32+
**`os lint` is the first consumer, and it tells the classes apart.** They
33+
used to share one catch, so they shared one fate:
34+
35+
- **accidental** (`free-identifiers`) — the handler *is* expressible as a
36+
metadata body; it merely names a module-scope const, helper or import. Now a
37+
lint **`error`**, so a gate can fail on it. `os lint` exits 1.
38+
- **structural** (`forbidden-token`) — `fetch`/`require`/`process`/… are
39+
capabilities the sandbox does not have, so writing one *is* choosing a bundled
40+
closure and the bundle is the designed answer. Reported as a **`warning`**,
41+
never fatal.
42+
- **instrument** (`unparseable`, or a failure of the extractor itself) — the
43+
tool could not judge the body at all. Reported as a **`warning`** under its
44+
own rule, with prose that names the instrument — never as if the author chose
45+
a bundle, because an instrument failure is not a verdict about the author.
46+
47+
An author who deliberately wants a bundled closure keeps two channels that
48+
already existed and are still silent: give the hook an explicit `body`, or move
49+
the function into the top-level `functions:` map and reference it by name.
50+
51+
**What did NOT change: what `os build` accepts.** The catch in `lowerCallables`
52+
stays, both classes still fall back to bundling, and the build still exits 0 —
53+
verified over a real spawned `os build`. Flipping that default is a separate
54+
contract decision. The new rule runs the *same* `extractHookBody` the build
55+
runs, so the lint verdict cannot drift from what the build would do to the same
56+
handler.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): carry the producer's `userMessage` at the dispatcher's PERMISSION_DENIED door (#13623)
6+
7+
`HttpDispatcher.dispatch`'s foot catch is not a pure rethrow: it recognises
8+
`isPermissionDeniedError` — `name === 'PermissionDeniedError'`, **or**
9+
`code === 'PERMISSION_DENIED'`, **or** a message starting `[Security] Access
10+
denied` — and answers the refusal itself from
11+
`packages/runtime/src/http-dispatcher.ts`. A marked denial therefore never
12+
reached `dispatcher-plugin`'s throw-transparent exit, which is the door #13241
13+
taught to carry the author-facing text channel, and lost the mark at this one
14+
instead. `ApiErrorSchema.userMessage` has declared the slot all along and
15+
`contract.zod.ts` states the invariant directly — *"`userMessage` on the thrown
16+
error; the boundaries carry it to the wire"* — so this is `declared ≠ enforced`
17+
at one more boundary, not a new field.
18+
19+
**Why this door matters more than its size.** #9934 made the mark
20+
status-agnostic precisely so a 403 could carry it, and a 403 is the refusal
21+
class most likely to carry deliberately-authored text ("You do not have access
22+
to this report; ask an admin for the Reporting role"). The one door that
23+
swallowed the mark was the one answering exactly those refusals.
24+
25+
**Transport parity, which the same ruling already asked for.**
26+
`@objectstack/rest`'s `mapDataError` has carried the field on this identical
27+
denial since #9934 (`withDeclaredUserMessage` over `classifyDataError`, whose
28+
`PERMISSION_DENIED` branch is pinned in
29+
`packages/rest/src/rest-user-facing-refusal-marking.test.ts`). The 2026-08-11
30+
ruling on #7450 makes REST's shape the contract for both transports; until now
31+
the dispatcher's 403 was the one that differed.
32+
33+
**⛔ #7450's disclosure withhold is untouched.** That ruling is about
34+
`error.details` — the gate's `positions` / `permissionSets` and the cascade
35+
child `object` the caller never addressed — and all of it is still dropped from
36+
the wire and still logged server-side. `userMessage` is the opposite by
37+
construction: it exists only because a producer wrote text *for* the caller,
38+
platform and driver code never set it, and it rides as a declared top-level
39+
sibling of `code`/`message`, never inside `details`. The read is
40+
`declaredUserMessage` (`@objectstack/types`), the one rule every boundary
41+
applies, so this door cannot fork its own answer to "what counts as marked".
42+
43+
**No behaviour change for an unmarked denial.** Its envelope is byte-identical
44+
to before — key set `code` / `details` / `httpStatus` / `message`, pinned — and
45+
the mark never moves the status or the `code`.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/docs": patch
3+
---
4+
5+
feat(docs): give a doc page a short sidebar label (`navTitle`) distinct from its `title` (#12311)
6+
7+
A doc page's frontmatter `title` was the only string the site had, so it served
8+
six consumers with different length budgets at once — measured on `origin/main`,
9+
405 pages under `content/docs`, whose only frontmatter keys are `title` (405) and
10+
`description` (405):
11+
12+
| consumer | site |
13+
|---|---|
14+
| SERP `title`, OG and twitter metadata | `app/[lang]/docs/[[...slug]]/page.tsx:211,216,227,233` |
15+
| on-page `h1` | `page.tsx:150` |
16+
| JSON-LD `TechArticle` headline/name | `page.tsx:123,124` |
17+
| JSON-LD `BreadcrumbList` | `page.tsx:95` — via the page tree |
18+
| `llms.txt`, `llms-full.txt`, the `.mdx` endpoints | `app/llms.txt/route.ts:10`, `lib/source.ts` `getLLMText` |
19+
| Open Graph card image | `app/og/docs/[...slug]/route.tsx:18` |
20+
| sidebar / page tree | `lib/source.ts` — `loader()` |
21+
22+
A 50–60 character title carrying search intent is right for the first six and
23+
unreadable in the last, which is why #12237's title rewrite stopped after the
24+
four pages that have no sidebar entry.
25+
26+
`navTitle` is the page tree's own string. It is declared on the docs page schema
27+
(`docsSchema = pageSchema.extend({ navTitle: z.string().optional() })` in
28+
`apps/docs/source.config.ts`) and resolved in exactly one place —
29+
`apps/docs/lib/nav-title.ts`, whose header is the mechanism's documentation —
30+
through `fumadocs-core`'s own `PageTreeTransformer` hook, the same extension
31+
point its built-in icon plugin uses. **`title` is the declared fallback**, stated
32+
there and at no read site, so all 405 pages keep their present sidebar entry with
33+
no frontmatter change.
34+
35+
`fumadocs-core@16.14.4` ships no first-class equivalent: its `pageSchema` is
36+
`{ title, description, icon, full, _openapi }`, its `metaSchema` carries no
37+
per-page label field, and its page-tree builder reads `{ title, description,
38+
icon }` off a page. `scripts/check-docs-nav-label.mjs` re-reads both schemas on
39+
every run, so the day an upgrade does ship one, the gate says migrate.
40+
41+
The separation is pinned rather than described. That gate holds `navTitle` to two
42+
code sites, executes the resolver over its fallback cases, and keeps the JSON-LD
43+
breadcrumb's leaf crumb on `page.data.title`: `getBreadcrumbItems` is now called
44+
with `includePage: false`, so the leaf comes from the page's own title instead of
45+
its page-tree node — behaviour-identical today (the node's name *is* the title),
46+
and the one place the short label would otherwise have reached structured data a
47+
crawler reads.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/service-datasource": patch
4+
"@objectstack/spec": minor
5+
---
6+
7+
Deleting a datasource now evicts its driver from the data-engine registry, so `/api/v1/ready` recovers without a process restart
8+
9+
**BREAKING** `IObjectQLEngine` gains a REQUIRED member, shipped as `minor`
10+
under the repo's launch-window convention for breaking changes.
11+
12+
`unregisterDriver(name: string): boolean` is additive for CONSUMERS — nothing
13+
they already call changes — but it breaks any third-party *implementer* of
14+
`IObjectQLEngine` at compile time, and the interface is on the published
15+
surface (`packages/spec/src/contracts/index.ts` re-exports it and `./contracts`
16+
is a published export path). Graded `minor` to match this contract's own
17+
precedent: the three prior changes to it all took `minor`, including one that
18+
added five members that were **all optional** and therefore broke nobody by
19+
construction. A required member grading below that would be inconsistent.
20+
21+
The ObjectQL driver registry had a `registerDriver` door and no counterpart, so
22+
nothing could ever leave it. Deleting a datasource emptied the admin door while
23+
`GET /api/v1/ready` kept naming the deleted datasource's driver — the readiness
24+
probe reports whatever `checkDriversHealth()` finds in that registry — and on a
25+
multi-replica deployment the only recovery was restarting every process.
26+
27+
`IObjectQLEngine` gains `unregisterDriver(name)`, the removal counterpart of
28+
`registerDriver`. The registry owns the invariant rather than each caller: an
29+
eviction has to drop the driver entry, clear the `defaultDriver` NAME when it
30+
pointed at the evicted driver (otherwise `getDefaultDriverName()` answers with a
31+
name nothing backs), and drop the datasource definition that has no removal door
32+
of its own. Evicting does not disconnect the pool — teardown belongs to whoever
33+
owns it.
34+
35+
Three lifecycle paths now use it:
36+
37+
- **Datasource delete / pool teardown** — `DatasourceConnectionService.disconnect()`
38+
evicts after closing the pool, which is the path `DELETE /api/v1/datasources/:name`
39+
reaches. The default driver is evicted under its natural registered name.
40+
- **Failed-start rollback** — a connect that throws after registering now rolls
41+
that registration back, instead of leaving a driver the admin list reports as
42+
failed and the readiness probe still pings.
43+
- **Engine teardown** — `destroy()` disconnects and then evicts, so a destroyed
44+
engine no longer reports drivers whose pools it has already closed.
45+
46+
Eviction is per-replica, matching how driver registration already works
47+
(each replica registers pools from the shared datasource records at boot);
48+
propagating it cluster-wide would need a broadcast channel the driver registry
49+
does not have today.
50+
51+
<!-- adr-0087: not-required (no-migration-prescription) this change is purely ADDITIVE: `IObjectQLEngine` gains a member and nothing is renamed, retired or converted, so there is no authored metadata for `objectstack migrate meta` to rewrite and no migration to prescribe. The breaking half is compile-time only, against third-party IMPLEMENTERS of the interface; consumers are unaffected. Deliberately NOT claimed as runtime-interface-only: this gate classifies packages/spec/src/contracts/** as a metadata surface, so that category is false here even though the symbol is a TypeScript interface with no Zod schema behind it. -->

0 commit comments

Comments
 (0)