Skip to content

Commit 7fe7c4e

Browse files
committed
Merge origin/main (edcc8ce, carrying #25114) into 7b-0
Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude <noreply@anthropic.com>
2 parents 31189e7 + edcc8ce commit 7fe7c4e

11 files changed

Lines changed: 1370 additions & 304 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `server.trustProxy`'s description names all three consumers of the client-address rule
6+
7+
Clause-②: no (prose only: no shape, key, default or accepted value moves)
8+
9+
The `.describe()` on `server.trustProxy` said the client-address rule had two consumers: the inbound rate limiter and the authentication plugin. There is a third. A declared endpoint (`apis:`) whose own `rateLimit` is enabled is metered by the endpoint policy chain, which keys anonymous callers by the same rule under the same `trustProxy`, whether or not `server.security.rateLimit.enabled` is set. The description and the TSDoc now name it. No key, type, default or accepted value changes.

‎.github/workflows/pr-automation.yml‎

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -751,46 +751,68 @@ jobs:
751751
1. It releases something
752752
-> run 'pnpm changeset' and name the packages it releases.
753753
754-
WHICH LEVEL: A purely additive widening of a published package's
755-
public surface (a new exported symbol on an `index`, a new accepted
756-
key or value) takes at least `minor`. The commit type may raise a
757-
bump but never lower it below what the act requires; a `fix(` that
758-
widens an index is therefore `minor`, and a `fix(` that changes no
759-
public surface stays `patch`. During the launch window `major` stays
760-
refused by `check-changeset-no-major` and breaking-ness is carried by
761-
the BREAKING banner plus the ADR-0087 disposition, not by the level.
762-
Ruled by the maintainer on 2026-09-04 (decision batch #35) on #15294,
763-
which recorded two contract reviews reading the repo's own history to
764-
OPPOSITE bumps for the same additive act. The commit type is not the
765-
discriminator it looked like: it correlates with the act, and when
766-
they disagree the act wins. The 64 historical `patch` precedents are
767-
pre-rule and nothing is retro-fixed.
754+
WHICH LEVEL: the level follows the METADATA PROTOCOL alone -- the
755+
change to what a metadata author can write -- and the commit type
756+
never decides it (maintainer ruling A, 2026-10-21, on #25105; it
757+
amends the 2026-09-04 rule of decision batch #35 on #15294, under
758+
which nearly every release was a minor: of 95 pending changesets
759+
about half were `minor` and about a dozen moved the protocol).
760+
Declare the kind on a line of its own, in the PR body AND in the
761+
changeset body, beside the `Clause-②:` line:
762+
763+
Metadata-protocol: added a new authorable key, a new accepted
764+
value or enum member, a new metadata
765+
type -> `minor`
766+
Metadata-protocol: narrowed a key removed or tombstoned, a value
767+
refused, a requirement tightened
768+
(declare the `(narrowing)` arm too)
769+
-> `minor`
770+
Metadata-protocol: semantic the meaning or default of an
771+
authorable key changes -> `minor`
772+
Metadata-protocol: none everything else: description or
773+
error text, non-authorable internal
774+
types, REST, drivers, services, the
775+
console, `feat:` and `fix!:` alike
776+
-> `patch`
777+
778+
`added` takes `minor` because spec schemas are not `.strict()`: an
779+
older runtime silently strips a key it does not know, so an app
780+
that writes a new key must be able to name a minor floor. Where
781+
several kinds apply, name the one highest in breaking-ness:
782+
`narrowed` over `semantic` over `added`. A non-protocol breaking
783+
change ships `patch` and KEEPS its carriers: the BREAKING banner
784+
and the ADR-0087 disposition are unchanged. `major` stays refused
785+
by `check-changeset-no-major` during the launch window. The
786+
GitHub Release body is grouped from these declarations by what the
787+
reader must do: 需手动处理 / 自动迁移 / 新增能力 / 语义变化 / 其他修复.
768788
769789
PART OF THIS IS A GATE, and which part is the difference between
770790
grading well and being refused. `check-changeset-no-major.mjs`
771-
refuses `major` and says why in its header. Since #16055 the SAME
772-
script also runs a LEVEL AXIS over the rule above, and that
773-
script's header states its scope: "a PR that DECLARES clause ② (a
774-
new key on a published payload) must grade AT LEAST ONE package
775-
whose published source it moves `minor` or above". It mechanizes
776-
this ruling "for exactly the PRs where the widening is already
777-
DECLARED, and for no others", and its `judgeLevel` verdict table
778-
spells both directions: `not-declared`, "the declaration reads
779-
`no`", exits 0; `enforce`, "declared `yes`, moved packages graded
780-
`patch` and NONE of them graded `minor` or above", exits 1. So on
781-
a PR that declares clause ②, grading `patch` across the board is
782-
refused; on every other PR the axis stands down and the two levels
783-
below `major` are yours, as they always were.
791+
refuses `major` and says why in its header. The SAME script runs
792+
the LEVEL AXIS over the rule above, reading both lines from the PR
793+
body on every `opened`, `synchronize` and `edited` event, and its
794+
`judgeLevel` verdict table spells every direction: a declared kind
795+
must grade at least ONE package whose published source the diff
796+
moves `minor` or above (`enforce`, exit 1 otherwise); a declared
797+
`none` must grade every entry it introduces `patch` (`none-minor`,
798+
exit 1) and may not move a `packages/spec/authorable-surface/` or
799+
`authorable-defaults/` shard (`none-baseline-moved`, exit 1);
800+
`added` beside `Clause-②: no`, or `narrowed` beside a line without
801+
the `(narrowing)` arm, is a `contradiction` (exit 1); and a PR that
802+
introduces a changeset entry with no readable `Metadata-protocol:`
803+
line is refused as NOT MEASURED (exit 1) rather than ticked -- the
804+
body is re-read on `edited`, so that red clears with no push.
784805
785806
WHAT THE AXIS DOES NOT JUDGE, in its own words: "WHICH of the
786807
packages above received that widening is NOT something this gate
787-
can read, and it does not claim to. Clause ② is declared ONCE, FOR
788-
THE PR" -- the declaration line in the PR body names no package.
789-
The axis cross-checks a declaration
790-
against the levels; it never reads the diff to find the act.
791-
Judging the act is still yours, this paragraph is still where the
792-
rule lives, and everything in it the axis does not reach is still
793-
prose.
808+
can read, and it does not claim to. The kind is declared ONCE, FOR
809+
THE PR" -- the declaration lines in the PR body name no package. A
810+
refused value, a narrowed enum or a changed meaning is invisible to
811+
the baselines, so the declaration, not the diff, is the carrier.
812+
The axis cross-checks the declarations against the levels; it never
813+
reads the diff to find the act. Judging the act is still yours,
814+
this paragraph is still where the rule lives, and everything in it
815+
the axis does not reach is still prose.
794816
795817
2. It releases nothing (.github/, .claude/, docs/, content/,
796818
examples/, tests-only, and the like)

‎AGENTS.md‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,24 +1081,24 @@ Both non-handshake shapes, and how to classify and probe your own:
10811081
surface** (Prime Directive #14 names them — more than ADRs): push it, open the PR, and stop
10821082
there; landing waits for its tier's record — the maintainer's word, or a seat's contract-tier
10831083
review. For that class, a finished task = a PR left visibly awaiting that record.
1084-
3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset`
1085-
(or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a
1086-
**`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes
1087-
nothing from any released package. The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair
1088-
`(widening)`/`(narrowing)`: `yes` takes at least **`minor`**, `(narrowing)` is BREAKING, `no (widening)` malformed.
1089-
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a
1090-
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
1091-
this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the
1092-
tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the
1093-
prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on the schema whether or not it is
1094-
`.strict()`, and an entry in the shape's `*_RETIRED_KEY_GUIDANCE` map (see `data/mapping.zod.ts`) only for a
1095-
spelling the shape never declared, such as the retired key's old alias, where a tombstone has no property to
1096-
replace. The changeset is one of fourteen surfaces a retirement touches — follow the
1097-
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note that a tombstone
1098-
keeps its liveness-ledger row while a key deleted without one loses it.
1099-
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
1100-
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
1101-
categories are NOT copied here — the gate prints the full set when it fails.
1084+
3. **Add a changeset for anything that publishes** (`pnpm changeset` before committing). A bug fix in a released
1085+
package takes a **`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that
1086+
publishes nothing from any released package. Two line-initial declarations, in the PR body and the changeset body:
1087+
`Clause-②: yes|no` plus at most one arm from the closed pair `(widening)`/`(narrowing)` (`(narrowing)` is
1088+
BREAKING, `no (widening)` malformed), and `Metadata-protocol: added|narrowed|semantic|none`, the change to what a
1089+
metadata author can write — a new key, accepted value or metadata type is `added`; a key removed or tombstoned, a
1090+
value refused or a requirement tightened is `narrowed`; a changed meaning or default is `semantic`; all else is
1091+
`none`; several ⇒ `narrowed` over `semantic` over `added`. The level follows the kind alone: a kind takes at least
1092+
**`minor`** on a package the diff moves, `none` takes **`patch`**; a non-protocol change, widening or breaking,
1093+
ships `patch` and keeps its BREAKING banner and ADR-0087 disposition. **Breaking changesets must carry their
1094+
migration**: removing or renaming anything an author can write (a spec key, an export, a config field) puts the FROM
1095+
→ TO mapping and the one-line fix in the body, which ships as `CHANGELOG.md` in the npm package. Removing an
1096+
authorable spec key also needs a tombstone (`retiredKey()`, `packages/spec/src/shared/retired-key.ts`) so the
1097+
rejection carries the prescription — one of fourteen retirement surfaces; follow the `spec-property-retirement`
1098+
skill (`.claude/skills/`), never reconstruct the kit. **A breaking changeset must also state its ADR-0087
1099+
disposition, in writing** — exactly one marker in the changeset body, which also carries both declaration lines
1100+
(`pnpm check:adr-0087-registration` reads the arm and the kind there). ⛔ The categories are NOT copied here —
1101+
the gate prints the full set when it fails.
11021102
4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it
11031103
does not ship.** The full battery's `Console Pin Gate` builds objectui at the pinned `.objectui-sha` against
11041104
**current** `main`, so such a removal is a main-red the moment it merges; its ruling therefore authorizes the

‎content/docs/references/system/stack-server.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const result = ServerRateLimitConfigSchema.parse(data);
101101
| Property | Type | Required | Description |
102102
| :--- | :--- | :--- | :--- |
103103
| **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. |
104-
| **trustProxy** | `boolean` | optional (default: `false`) | Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is the transport's own peer address, which a client cannot forge. One client-address rule, two consumers: the inbound rate limiter when `server.security.rateLimit.enabled` is set, and the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address. |
104+
| **trustProxy** | `boolean` | optional (default: `false`) | Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is the transport's own peer address, which a client cannot forge. One client-address rule, three consumers: the inbound rate limiter when `server.security.rateLimit.enabled` is set; the per-endpoint rate limiter of every declared endpoint (`apis:`) whose own `rateLimit` is enabled, whether or not the server-level one is; and the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address. |
105105

106106
### Nested Shape: `StackServerConfig.security`
107107

‎docs/releases-maintenance.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ repo and bundled into `@objectstack/console` at a pinned commit recorded in
3939
[`.objectui-sha`](../.objectui-sha). A SHA bump alone used to leave **no trace** in
4040
the history. `scripts/bump-objectui.sh` now closes that gap: on every bump it emits
4141
a `@objectstack/console` changeset summarising the objectui `OLD_SHA..NEW_SHA`
42-
commit range (auto-typed `minor` if the range contains any `feat`, else `patch`).
43-
So frontend deltas flow through the *same* pipeline as the backend — they land in
44-
`@objectstack/console`'s changelog and roll up into the platform version.
42+
commit range, graded `patch`: the platform's version line follows the metadata
43+
protocol, which the console never changes, so the file carries
44+
`Metadata-protocol: none` and `CONSOLE_BUMP` is the only thing that grades it
45+
otherwise. So frontend deltas flow through the *same* pipeline as the backend —
46+
they land in `@objectstack/console`'s changelog and roll up into the platform
47+
version.
4548

4649
```bash
4750
scripts/bump-objectui.sh # bump to ../objectui HEAD, write the changeset, commit
@@ -167,6 +170,25 @@ platform version maps to.
167170
page under a "What's new in N.x" running section, or leave them to the generated
168171
per-package changelogs.
169172

173+
### Release notes are grouped by what the reader must do
174+
175+
The GitHub Release body is not the raw CHANGELOG section. `scripts/release-github-releases.mjs`
176+
regroups that section's entries — byte for byte, none dropped — under five headings, in this
177+
order, each read from the declarations the changeset already carries (`Metadata-protocol:`, the
178+
`Clause-②:` arm, the BREAKING banner, the ADR-0087 disposition marker):
179+
180+
| Section | What places an entry there |
181+
|:--|:--|
182+
| 需手动处理 | breaking (`Metadata-protocol: narrowed`, a `(narrowing)` arm, a BREAKING banner or a `!` summary) with no `registered` ADR-0087 conversion |
183+
| 自动迁移 | breaking, with a `registered` conversion that `objectstack migrate meta` applies |
184+
| 新增能力 | `Metadata-protocol: added`, or a non-breaking `Clause-②: yes` widening |
185+
| 语义变化 | `Metadata-protocol: semantic` |
186+
| 其他修复 | everything else — `none`, dependency bumps, and entries written before the declaration existed |
187+
188+
A section with nothing in it is omitted. A curated `vN.mdx` section for the same release uses the
189+
same five headings in the same order, so the hand-written page and the generated body agree on
190+
what a reader must do; the per-package `CHANGELOG.md` keeps changesets' own level grouping.
191+
170192
### Sourcing the Console section for a release
171193

172194
Backend content comes from the spec/package changesets. Frontend content comes from

‎packages/spec/src/system/stack-server.zod.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,19 +210,24 @@ export const StackServerConfigSchema = lazySchema(() => strictObject(
210210
* of authorship — the operator asserting "a proxy I control rewrites this
211211
* header on every request" — and never a default.
212212
*
213-
* Both consumers apply one rule, `resolveClientAddress` from
213+
* Three consumers apply one rule, `resolveClientAddress` from
214214
* `@objectstack/plugin-auth/client-address`: the runtime's inbound rate
215-
* limiter, and `AuthPlugin` (passed this value by `objectstack serve`),
216-
* whose ADR-0069 D5 IP allow-list and D7 `last_login_ip` audit use the
217-
* address it establishes — so a header believed without such a proxy is
218-
* an allow-list bypass as well as a rate-limit one.
215+
* limiter; the endpoint policy chain's per-endpoint rate limiter, for
216+
* every declared endpoint whose own `rateLimit` is enabled, whether or not
217+
* the server-level budget is; and `AuthPlugin` (passed this value by
218+
* `objectstack serve`), whose ADR-0069 D5 IP allow-list and D7
219+
* `last_login_ip` audit use the address it establishes — so a header
220+
* believed without such a proxy is an allow-list bypass as well as a
221+
* rate-limit one.
219222
*/
220223
trustProxy: z.boolean().default(false).describe(
221224
'Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy '
222225
+ 'you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is '
223-
+ "the transport's own peer address, which a client cannot forge. One client-address rule, two consumers: the "
224-
+ 'inbound rate limiter when `server.security.rateLimit.enabled` is set, and the authentication plugin, whose IP '
225-
+ 'allow-list (`allowed_ip_ranges`) judges and whose login audit stamps (`last_login_ip`) the same address.',
226+
+ "the transport's own peer address, which a client cannot forge. One client-address rule, three consumers: the "
227+
+ 'inbound rate limiter when `server.security.rateLimit.enabled` is set; the per-endpoint rate limiter of every '
228+
+ 'declared endpoint (`apis:`) whose own `rateLimit` is enabled, whether or not the server-level one is; and the '
229+
+ 'authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges and whose login audit stamps '
230+
+ '(`last_login_ip`) the same address.',
226231
),
227232
},
228233
));

0 commit comments

Comments
 (0)