Skip to content

Commit ac8eb42

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-8019-change-email-old-address-notice
2 parents 196aab1 + 9172fa2 commit ac8eb42

40 files changed

Lines changed: 3354 additions & 148 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): `actionLooksDestructive` classifies on declared semantics only (#7828)
6+
7+
`actionLooksDestructive` (the classifier behind the MCP `list_actions` tool's
8+
`requiresConfirmation` field) treated the mere presence of `confirmText` — UI
9+
dialog copy — as an AI-facing destructiveness signal. #7278/#7309 are actively
10+
migrating authors away from pairing `confirmText` with `params`-bearing actions
11+
(the confirm question now rides `description` instead), so the heuristic's
12+
input was being withdrawn by design: measured on #7309's branch, 6 of its 14
13+
migrated identity actions flipped from destructive to not-destructive the
14+
moment their `confirmText` was dropped, because none of them declares
15+
`mode: 'delete'` or `variant: 'danger'` to fall back on.
16+
17+
Maintainer ruling (issue #7828, Option A): drop the `confirmText` leg.
18+
`mode === 'delete' || variant === 'danger'` remain the signal — closed,
19+
declared enumerations an author sets on purpose, not UI copy a heuristic
20+
was never meant to read as a safety property.
21+
22+
This path is gated dead for every action shipped today (all 14 identity
23+
actions are `sys_*`, `type: 'api'`, and none declares `ai.exposed: true`, so
24+
none reaches the MCP `listActions` bridge that calls this classifier) — so
25+
the change has no observable effect on any request a caller can make right
26+
now. It closes the gap before a future `ai.exposed`, non-`sys_*` action
27+
carrying only `confirmText` would have had its classification silently flip.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
docs: fix `business_unit` sharing-rule docstrings that still attributed the BU subtree expansion to the narrow recipient (#8098)
7+
8+
#7807 (PR #8097, `9b519815`) narrowed the `business_unit` sharing-rule
9+
recipient to expand exactly one unit's members, moving the subtree walk onto
10+
`unit_and_subordinates`. Two docstrings never got the memo:
11+
`IBusinessUnitGraphService` in `packages/spec/src/contracts/sharing-service.ts`
12+
and the `sys_business_unit` object definition in
13+
`packages/platform-objects/src/identity/sys-business-unit.object.ts`. Both
14+
still said `recipient_type='business_unit'` sharing rules were driven by the
15+
subtree walk. Both now name `unit_and_subordinates` as the subtree consumer,
16+
with `business_unit` as the narrow (single-unit) one.
17+
18+
These are comment-only corrections — the `IBusinessUnitGraphService`
19+
docstring surfaces in `@objectstack/spec`'s built `dist/**/*.d.ts` hover, and
20+
the `sys_business_unit` docstring surfaces in
21+
`@objectstack/platform-objects`'s built `dist/**/*.d.ts` hover; no runtime or
22+
authoring behaviour changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
`visibility-root-mislayered` and `visibility-bare-identifier` now explain the metadata-editing layer by naming the **surface** — a schema-bound metadata-editing form, the row under edit — instead of a `*.form.ts` filename.
6+
7+
Since the layer derivation landed (#7815), a form view declaring `data: { provider: 'schema', schemaId }` is judged at the metadata layer at the runtime publish gate. That door's audience is a Studio / REST `/meta` / MCP author who has no `*.form.ts` to open, so the prose justified a correct prescription by pointing at a file the reader cannot reach. The mirror (runtime) arm named `*.view.ts` / `*.page.ts` the same way and is fixed with it.
8+
9+
Prose only: no rule id, severity, prescribed root or firing condition changes, and the actionable half of every message and hint is unchanged. The `*.form.ts` mentions addressed to a **file-aware caller** of `validateVisibilityPredicates` (the `opts.layer` contract) are deliberately kept — there the filename is accurate and is the point.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the D5.1 `/oauth2/authorize` env-access gate now runs for a signed bearer credential (#8102)
6+
7+
ADR-0069 D5.1's cloud-as-IdP gate (`oidcAuthorizeGate`) is what enforces
8+
org-membership / app-assignment before the OP issues an authorization code. It
9+
resolved its subject with an **inline copy** of the shared `resolveActor`
10+
line for line the same logic, in a second place — and the two diverged the
11+
moment one of them was fixed.
12+
13+
`#8049` taught `resolveActor` that a bearer credential must have its signature
14+
stripped before lookup: `bearer()` hands clients the signed form in the
15+
`set-auth-token` response header (the documented API-lane credential) and
16+
accepts it back, while `session.token` stores the **unsigned** value. The copy
17+
guarding `/oauth2/authorize` kept looking the signed credential up verbatim and
18+
so resolved nothing.
19+
20+
**Why that is a security defect and not a lookup miss.** The unresolved case at
21+
this endpoint is deliberately **fail-open** — an anonymous caller must fall
22+
through so the OP can redirect them to log in. So an *authenticated* caller
23+
holding the signed bearer was read as unauthenticated, and the env-access check
24+
was not denied but **never evaluated at all**: the request proceeded, and
25+
against a `skip_consent` client it was issued an authorization code that the
26+
gate, had it run, would have refused. A declared control enforced for one
27+
credential spelling and silently absent for the other.
28+
29+
Impact is bounded: `oidcAuthorizeGate` is set only on the cloud control plane
30+
(unset in open editions / self-host, where there is no gate at all), and the
31+
OP's authorize endpoint is normally browser/cookie-driven — the cookie branch
32+
always normalized and was never affected.
33+
34+
**Fix.** The inline copy is deleted; the branch calls the shared
35+
`resolveActor`, so there is one resolution site instead of two. The fail-open
36+
default for genuinely unauthenticated callers is unchanged and deliberately
37+
preserved.
38+
39+
Pinned by a new dogfood gate that arms a **denying** gate and drives
40+
`/oauth2/authorize` over the cookie lane and both accepted bearer spellings,
41+
asserting on each that the gate was actually invoked with the caller as its
42+
subject and that the request was refused rather than issued a code.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
Refuse deletion of a platform-global sharing rule to org-scoped callers (#7795)
6+
7+
`SharingRuleService.deleteRule` now requires **platform** authority to delete a
8+
sharing rule whose `organization_id` is `null` — a row seeded from declared
9+
metadata that belongs to no organization. A caller holding only the org-scoped
10+
`manage_sharing` capability is refused with `PERMISSION_DENIED`, which the REST
11+
layer answers as **403**; the `manage_platform_settings` capability, the
12+
built-in `platform_admin` position, and system contexts are all still permitted.
13+
14+
Why: such a rule's criteria query runs unscoped, so deleting it purged **every**
15+
tenant's `sys_record_share` grants under it — a cross-tenant destructive act
16+
authorized by a capability declared `scope: 'org'`. Two measured facts made it
17+
worse: the boot seeder re-creates the rule on the next restart under a *new* id,
18+
so the delete was a revocation wearing removal's clothes rather than a removal;
19+
and the safe lever was unavailable while the destructive one was not — an org
20+
admin's `active: false` creates a second, org-stamped row and leaves the shared
21+
rule running, so deletion was the only lever an org admin had over it.
22+
23+
Deliberately **403, not 404**: the row is intentionally visible — listing,
24+
reading and evaluating platform-global rules stay open to org admins, exactly as
25+
shipped — so answering "no such rule" would contradict a read the same caller
26+
can perform one call earlier. Nothing on the read/evaluate surface changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
`PUT /api/v1/automation/:name` answers 400 `VALIDATION_FAILED`, not 500, for a malformed flow definition — matching `POST /api/v1/automation` (#8123)
6+
7+
#8055 reclassified `POST /`'s `registerFlow` refusal from 500 `INTERNAL_ERROR` to 400 `VALIDATION_FAILED`. `PUT /:name` makes the identical `registerFlow` call in the same file and was left uncaught, so the two doors disagreed about the class of an identical refusal: publishing a flow through update got a different answer than publishing through create, for the same broken definition.
8+
9+
`PUT /:name` now routes its `registerFlow` call through the same `flowDefinitionRefusal` helper `POST /` uses, so both doors answer the same `code`, `status`, and `details.fields[]` shape for the same malformed body — including the #4277 self-correcting undeclared-config-key message, which survives verbatim.
10+
11+
**Which bodies are refused is unchanged** on both routes — a definition that registered before still registers, and every one that was refused is still refused, with the engine's own message intact.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): one error envelope across the `/security/explain` pair (#8073)
6+
7+
`registerSecurityExplainEndpoints``GET/POST /api/v1/security/explain` and
8+
`GET /api/v1/security/my-delegable-scope` — answered two retired dialects across
9+
its eight refusal arms: the 401 / 501 / 400 / 403 arms were flat
10+
`{ code, message }`, and the two 500s were `{ code, error: 'a bare string' }`. So
11+
`body.error.code`, the one position ADR-0112 D5 declares, read `undefined` on all
12+
six — while the immediately adjacent registrar (`/security/suggested-bindings`,
13+
converged in #7981) already answered the declared shape. A client calling
14+
`explain` and then `suggested-bindings` met two envelopes inside one `security`
15+
family.
16+
17+
Every arm now emits `{ success: false, error: { code, message } }` through the
18+
shared `sendError` from `@objectstack/types` — the same builder every conformant
19+
route module writes through — so the family agrees by construction rather than by
20+
eight literals happening to match. The 400 arm's Zod-issue dump moves from a
21+
top-level `detail` sibling to `error.details`, the slot `ApiErrorSchema` declares
22+
for structured context.
23+
24+
No status code moves, and no code VALUE changes: `UNAUTHORIZED`,
25+
`NOT_IMPLEMENTED`, `VALIDATION_FAILED`, `PERMISSION_DENIED`, `EXPLAIN_FAILED` and
26+
`DELEGABLE_SCOPE_FAILED` are all already registered, so nothing in
27+
`packages/spec` moves. `ObjectStackClient` reads both envelopes' declared spots
28+
(`errorBody?.code ?? errorBody?.error?.code`, and a bare-string limb for the
29+
message), so `client.security.explain()` and
30+
`client.security.describeDelegableScope()` keep throwing identical `err.code` and
31+
`err.message` — re-measured against these call paths rather than inherited from
32+
#7981. `err.details` does change on refusals, from "the whole response body" to
33+
the structured slot or the new body.

.github/workflows/lint.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,22 @@ jobs:
333333
- name: Normalized metadata-type guard
334334
run: pnpm check:meta-type-normalized
335335

336+
# Filter-slot wire-alias parity guard (#8002). The ONE filter slot is
337+
# spelled four ways, declared in TWO packages: `where`/`filter` come from
338+
# the spec table, while the wire-only `filters`/`$filter` are declared in
339+
# `metadata-protocol` (no schema declares them) and named LITERALLY in
340+
# `packages/rest`, which gates the slot's arity at the querystring ingress
341+
# (#7390). A runtime import cannot join them — the normalizer's table is a
342+
# module-private const, and metadata-protocol is only a devDependency of
343+
# rest — so a fifth wire-only spelling would fold correctly and be
344+
# silently ungated, putting repetition on it back on the misdiagnosis
345+
# #7390 removed. The REST side alone is pinned by that card's §5 test;
346+
# this covers the half it cannot reach. AST-based, and an unreadable
347+
# declaration is RED rather than an empty corpus reported as a pass
348+
# (#4690). Runs its own --self-test first.
349+
- name: Filter-slot wire-alias parity guard
350+
run: pnpm check:filter-alias-parity
351+
336352
# Init-service declaration guard (#4471, ADR-0116). The kernel's ordering
337353
# contract (dependencies / optionalDependencies / requiresServices /
338354
# providesServices) was complete but VOLUNTARY: a plugin that resolves
@@ -1081,6 +1097,42 @@ jobs:
10811097
- name: Check the decision frame is in sync across its four copies
10821098
run: pnpm check:skill-frame-sync
10831099

1100+
# The SECOND skill-frame gate — and deliberately only HALF of it (#8110).
1101+
#
1102+
# `pnpm check:skill-frame-freshness` is `--self-test && the scan`, and only the
1103+
# SCAN half is CI-hostile: it asks "is THIS WORKING TREE's frame current with
1104+
# origin/main?", which on a fresh merge ref is a tautological green, and on any
1105+
# PR that legitimately edits the frame would be a false red — "differs from main"
1106+
# is that PR's whole purpose. That script's own header ("WHERE IT RUNS") argues
1107+
# it, and the scan stays out of CI for exactly that reason. It is NOT wired here.
1108+
#
1109+
# The `--self-test` half is a different animal. It DERIVES a coherent two-axis
1110+
# specimen from this tree's own frame documents, commits it and the real
1111+
# documents into throwaway git repos whose `origin` points at a path that does
1112+
# not exist, and asserts 12 verdicts against the gate's own code. No network, no
1113+
# origin/main, no history: measured green in a `--depth 1` clone carrying no
1114+
# remote-tracking ref at all, in ~1.3s. So the half that CANNOT run in CI was
1115+
# keeping the half that CAN out of it, and the price was measured on #8024 —
1116+
# the fixture had stranded TWICE, neither time noticed, while the gate sat in
1117+
# the gate list reading as coverage and protecting nothing.
1118+
#
1119+
# This step cannot redden a PR that edits the frame on purpose: the specimen is
1120+
# re-derived from whatever the frame currently is, so a reword, a reflow or a
1121+
# principles-only rewrite carries the fixture with it (verified adversarially
1122+
# before wiring this — rewording axis prose across all three frame files keeps
1123+
# both gates green). What DOES redden it is a STRUCTURAL change — an axis added,
1124+
# removed, or its declaring sentence reworded past the shared anchors — and that
1125+
# reddens `check:skill-frame-sync` directly above with the same diagnosis, which
1126+
# the self-test's own failure message names as the thing to fix first. No PR can
1127+
# reach this step red with the step above green.
1128+
#
1129+
# Invoked as `node`, NOT `pnpm check:skill-frame-freshness`, precisely because
1130+
# that npm script would drag the scan half in with it — the same shape as the
1131+
# `dev` chain calling check-dev-prereqs.mjs directly rather than through its
1132+
# pnpm script (see that script's header).
1133+
- name: Self-test the skill-frame freshness gate (self-test half only, never the scan)
1134+
run: node scripts/check-skill-frame-freshness.mjs --self-test
1135+
10841136
# The third gate over SKILL.md, covering the one line the other two cannot see
10851137
# (#5331). check:skill-docs / check:skill-refs compare generated artifacts and
10861138
# check:skill-examples typechecks `os:check` blocks; none of them reads the

examples/app-showcase/e2e/bulk-capability-gate.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { test, expect } from '@playwright/test';
2727
*/
2828

2929
// Ambient `process` for the env read below — the showcase tsconfig doesn't pull
30-
// in `@types/node`, and the package-global shim in test/node-shim.d.ts declares
30+
// in `@types/node`, and the package-global shim in types/node-shim.d.ts declares
3131
// only `cwd()`. Same idiom (and same reason) as the declarations in
3232
// objectstack.config.ts and src/system/self-url.ts: keeps `pnpm typecheck` green
3333
// without widening the type surface. Playwright provides the real `process`.

examples/app-showcase/e2e/detail-shapes.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { test, expect } from '@playwright/test';
2727
*/
2828

2929
// Ambient `process` for the env reads below — the showcase tsconfig doesn't pull
30-
// in `@types/node`, and the package-global shim in test/node-shim.d.ts declares
30+
// in `@types/node`, and the package-global shim in types/node-shim.d.ts declares
3131
// only `cwd()`. Same idiom (and same reason) as the declarations in
3232
// objectstack.config.ts and src/system/self-url.ts: keeps `pnpm typecheck` green
3333
// without widening the type surface. Playwright provides the real `process`.

0 commit comments

Comments
 (0)