diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000000..23c2061c87
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,27 @@
+# .github/CODEOWNERS — governance routing (#6785; ruling #6741).
+#
+# The ruling this file helps enforce (maintainer, verbatim):
+# 「adr 只能由维护者自己确认,人工合并,ai 不得擅自合并。」
+#
+# Routing docs/adr/ to the maintainer does two things: every PR touching an
+# ADR requests his review automatically, and a ruleset / branch-protection
+# rule "require review from Code Owners" has an entry to bind to (Half 2 of
+# #6785 — a repo-settings change only the maintainer can apply; the
+# button-by-button instructions are in the PR that added this file). The CI
+# half of the same enforcement is .github/workflows/adr-merge-approval.yml +
+# scripts/check-adr-merge-approval.mjs.
+#
+# `hotlong` is verified against the repo history, not assumed: 2,153 commits
+# on main are authored as 50353452+hotlong@users.noreply.github.com — GitHub's
+# {id}+{login} noreply address form ties the login to the account.
+#
+# The enforcement chain itself is routed the same way, deliberately: a PR
+# that edits the gate script, its workflow, or this file is a governance
+# change — without these entries the docs/adr/ rule could be lifted by
+# editing the enforcers instead of the ADRs, and the CI check alone must stay
+# scoped to docs/adr/** (its own PR has to pass the not-an-ADR-diff path).
+
+/docs/adr/ @hotlong
+/.github/CODEOWNERS @hotlong
+/.github/workflows/adr-merge-approval.yml @hotlong
+/scripts/check-adr-merge-approval.mjs @hotlong
diff --git a/.github/workflows/adr-merge-approval.yml b/.github/workflows/adr-merge-approval.yml
new file mode 100644
index 0000000000..73888789e8
--- /dev/null
+++ b/.github/workflows/adr-merge-approval.yml
@@ -0,0 +1,83 @@
+name: ADR Merge Approval
+
+# Machine enforcement of the #6741 ruling (maintainer, verbatim):
+# 「adr 只能由维护者自己确认,人工合并,ai 不得擅自合并。」
+#
+# A PR whose diff touches docs/adr/** must carry an APPROVED review from the
+# maintainer's OWN account before it is mergeable; approvals from the shared
+# bot/agent identities deliberately do not count. Prose enforcement was
+# measured insufficient the day the ruling landed — two different AI-operated
+# seats merged docs/adr/** PRs within the following hour (#6671, #6732; the
+# full record and both replays live in scripts/check-adr-merge-approval.mjs
+# and its --self-test). Drafting ADR PRs stays open to every seat; only the
+# merge is reserved, and the maintainer's own approval + merge is the intended
+# zero-extra-friction green path.
+#
+# Deliberately NO `paths` filter, on either trigger — the same choice
+# changeset-presence.yml made in objectui (#3769) and for the same reason
+# (objectui#3523): a path filter skips the WHOLE workflow, so the check
+# context is never CREATED on a non-matching PR, and a required context that
+# never reports leaves the PR pending in the merge queue until the ruleset's
+# 60-minute timeout. This gate reports on every PR instead: the script reads
+# the diff and decides, and a diff that does not touch docs/adr/** passes
+# with zero API lookups.
+#
+# This file is one of the enforcement surfaces .github/CODEOWNERS routes to
+# the maintainer: weakening or removing the gate is itself a governance
+# change and carries the same review requirement the gate enforces.
+
+on:
+ pull_request:
+ branches: [main]
+ # An approval does not fire `pull_request`, so without this trigger the
+ # failed check would sit red after the maintainer approves until someone
+ # re-ran it by hand. Subscribing to reviews makes the maintainer's approval
+ # itself re-run the gate — the zero-friction green path the card requires.
+ # (On non-ADR PRs a review re-runs the cheap clean path; harmless.)
+ pull_request_review:
+ types: [submitted, edited, dismissed]
+ # Merge queue (objectui#3523; see ci.yml's trigger block): a required
+ # context must report on queue builds or the queue stalls. On this event
+ # the script resolves the PR from the gh-readonly-queue ref (falling back
+ # to the head commit subject, then the commit's associated PRs) and fails
+ # loud if it cannot — never a silent skip.
+ merge_group:
+ types: [checks_requested]
+
+concurrency:
+ group: adr-merge-approval-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ # The gated path lists the PR's reviews via the REST API.
+ pull-requests: read
+
+jobs:
+ adr-merge-approval:
+ name: ADR maintainer approval
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ # The gate diffs against the merge base with the target branch (or
+ # the merge group's base); a depth-1 clone has no merge base, and an
+ # unresolvable base is a hard failure in the script, never a skip.
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: '22'
+
+ # A checkout, setup-node, and one `node` call over `git diff` — no
+ # install, no build. The self-test runs first (repo convention), then
+ # the gate. GITHUB_TOKEN is only read on the gated path (a docs/adr/**
+ # diff needs the PR's review list); the clean path does zero lookups.
+ - name: Require the maintainer's own approval on docs/adr/** diffs
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs
diff --git a/package.json b/package.json
index 7edc551c01..f62c35ee9f 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs",
"check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs",
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
+ "check:adr-merge-approval": "node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs",
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs",
"check:org-identifier": "node scripts/check-org-identifier.mjs",
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs",
diff --git a/scripts/check-adr-merge-approval.mjs b/scripts/check-adr-merge-approval.mjs
new file mode 100644
index 0000000000..a08d5801b3
--- /dev/null
+++ b/scripts/check-adr-merge-approval.mjs
@@ -0,0 +1,735 @@
+#!/usr/bin/env node
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// check-adr-merge-approval -- a PR whose diff touches docs/adr/** must not be
+// mergeable without an APPROVED review from the maintainer's own account.
+//
+// node scripts/check-adr-merge-approval.mjs # gate mode (CI and local)
+// node scripts/check-adr-merge-approval.mjs --pr 6671 # replay a PR via the live API
+// node scripts/check-adr-merge-approval.mjs --files-json f.json --reviews-json r.json
+// node scripts/check-adr-merge-approval.mjs --self-test # the checker itself
+//
+// ## The ruling this enforces (#6741, maintainer, verbatim)
+//
+// 「adr 只能由维护者自己确认,人工合并,ai 不得擅自合并。」
+//
+// ADR merges are reserved to the maintainer in person. Drafting and pushing
+// ADR PRs stays open to every seat -- this gate reserves the MERGE, nothing
+// else, and the maintainer's own approval + merge is the intended
+// zero-extra-friction green path.
+//
+// ## Why a machine gate rather than prose (#6785, measured)
+//
+// The ruling was filed at 13:52Z on 2026-08-08. Within the following hour, two
+// DIFFERENT AI-operated seats merged docs/adr/** PRs anyway: #6671 at 14:23Z
+// (`os-zhuang`) and #6732 at 14:38Z (`os-project-manager` -- while the PR was
+// in DRAFT state, so parking a PR as draft is not a barrier either). Neither
+// merge was the maintainer's; both had ZERO reviews of any kind. A ruling
+// written into an issue does not reach sessions that never read that issue.
+// This repo's own doctrine -- declared = enforced -- applies to governance
+// exactly as it applies to metadata. Both violations are replayed as
+// fixtures in `--self-test`, pinned RED forever.
+//
+// ## The decision rule
+//
+// diff does not touch docs/adr/** -> PASS, with ZERO API lookups
+// diff touches docs/adr/** -> PASS only if the PR carries an
+// APPROVED review from the maintainer's
+// own account (bot/agent approvals do
+// not count)
+//
+// ## Why the approver set is a hard-coded constant
+//
+// `MAINTAINER_APPROVERS` below is deliberately NOT configurable via repo/org
+// Actions variables, workflow inputs, or environment: every one of those
+// surfaces is writable by the shared AI identities (`os-*`, `claude`,
+// `yinlianghui`, ...), and those identities granting themselves approval
+// rights is precisely the failure mode this gate exists to close (#6785).
+// Matching is by numeric account ID, not login: a login can be released and
+// re-registered by someone else; the ID cannot. Changing the accepted set is
+// a governance change -- a reviewed PR to this file, which .github/CODEOWNERS
+// routes to the maintainer.
+//
+// ## Never a filtered trigger, never a silent skip
+//
+// The workflow (`.github/workflows/adr-merge-approval.yml`) subscribes with NO
+// path filter: a path-filtered required check never creates a run on
+// non-matching PRs, and a required context that never reports hangs the merge
+// queue until the ruleset's 60-minute timeout (objectui#3523; restated in
+// objectui#3769, the presence-gate pattern this gate follows). The script
+// reads the diff and decides. And every missing input -- unresolvable diff
+// base, failed `git diff`, unresolvable PR number on a merge_group build,
+// unfetchable review list -- fails LOUD (exit 1), never exit 0: a gate that
+// cannot see its inputs and passes anyway is the #4690 anti-pattern, restated
+// by the objectstack#4928 filter contract. The direction matters: for a filter
+// deciding whether to RUN work, "cannot tell" means run; here the work IS the
+// decision, so "cannot tell" means fail.
+//
+// ## merge_group semantics
+//
+// On a queue build, the PR is resolved from the queue ref
+// (`gh-readonly-queue//pr--` embeds the number), falling back to
+// the head commit's subject (`Merge pull request #N ...` / `... (#N)`), then to
+// the commit's associated PRs via the API. Unresolvable -> RED, never skip.
+// The diff is narrowed to this PR's own slice of the group (HEAD^1..HEAD) when
+// the head commit provably belongs to the ref-named PR; otherwise the whole
+// group diff (merge_group.base_sha..HEAD) is used -- a fail-CLOSED
+// over-approximation: an innocent PR queued behind an unapproved ADR PR may go
+// red for one build, but the ADR PR itself goes red too, is evicted, and the
+// innocent PR rebuilds green. The open direction (skipping) is the one that
+// can never be tolerated here.
+
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+
+/** The governed surface. A path prefix, matched against repo-relative paths. */
+export const ADR_PATH_PREFIX = 'docs/adr/';
+
+/**
+ * The accounts whose APPROVED review satisfies this gate. See the header for
+ * why this is a hard-coded constant and why matching is ID-first.
+ *
+ * `hotlong` = 50353452 is verified, not assumed: 2,153 commits on `main` are
+ * authored as `50353452+hotlong@users.noreply.github.com`, and GitHub's
+ * noreply address form is `{id}+{login}@users.noreply.github.com`, which ties
+ * the login to the ID in the repo's own history.
+ */
+export const MAINTAINER_APPROVERS = [{ login: 'hotlong', id: 50353452 }];
+
+/** Review states that SET the reviewer's standing; COMMENTED/PENDING do not. */
+const STATE_SETTING = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
+
+// -- pure decision functions --------------------------------------------------
+// Pure over their inputs so `--self-test` and the replay modes drive the REAL
+// functions with fixtures, not imitations.
+
+/** @param {string[]} paths @returns {string[]} the paths under docs/adr/ */
+export function adrFilesIn(paths) {
+ return paths.filter((p) => p.startsWith(ADR_PATH_PREFIX));
+}
+
+/**
+ * Does this review's author count as the maintainer?
+ *
+ * ID-first: when the payload carries a numeric id (the real API always does),
+ * the id alone decides -- a review from an account merely NAMED like the
+ * maintainer, with a different id, does not count. The login fallback exists
+ * only for hand-built fixtures that omit ids.
+ */
+export function isMaintainer(user, approvers = MAINTAINER_APPROVERS) {
+ if (!user) return false;
+ return approvers.some((a) => (user.id != null ? user.id === a.id : user.login === a.login));
+}
+
+/**
+ * The maintainer's CURRENT review standing, from the full review list.
+ *
+ * Reviews are walked in submission order (the API returns them ascending;
+ * `submitted_at` is used as the tiebreak-stable sort key when present). Only
+ * APPROVED / CHANGES_REQUESTED / DISMISSED change the standing -- a later
+ * COMMENTED does not revoke an approval, a later CHANGES_REQUESTED or a
+ * dismissal does.
+ *
+ * @returns {string|null} the latest state-setting state, or null when the
+ * maintainer has never reviewed
+ */
+export function latestMaintainerReviewState(reviews, approvers = MAINTAINER_APPROVERS) {
+ const mine = reviews
+ .filter((r) => isMaintainer(r?.user, approvers))
+ .map((r, i) => ({ r, i }))
+ .sort((a, b) => {
+ const ta = a.r.submitted_at ? Date.parse(a.r.submitted_at) : 0;
+ const tb = b.r.submitted_at ? Date.parse(b.r.submitted_at) : 0;
+ return ta - tb || a.i - b.i;
+ });
+ let state = null;
+ for (const { r } of mine) {
+ const s = String(r.state ?? '').toUpperCase();
+ if (STATE_SETTING.has(s)) state = s;
+ }
+ return state;
+}
+
+/** Logins whose APPROVED reviews exist but deliberately do not count. */
+export function approvalsFromNonMaintainers(reviews, approvers = MAINTAINER_APPROVERS) {
+ return [
+ ...new Set(
+ reviews
+ .filter((r) => String(r?.state ?? '').toUpperCase() === 'APPROVED' && !isMaintainer(r?.user, approvers))
+ .map((r) => r?.user?.login ?? '(unknown)'),
+ ),
+ ];
+}
+
+/**
+ * The whole judgement. `getReviews` is a LAZY async thunk: on a diff that does
+ * not touch docs/adr/** it is never invoked, which is how "PASS with zero API
+ * lookups" is a structural property rather than a promise -- the self-test
+ * passes a thunk that throws, proving the clean path cannot look anything up.
+ *
+ * @param {object} input
+ * @param {string[]} input.changedPaths repo-relative changed paths
+ * @param {() => Promise