Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
Skip to content

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) - #13659

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7
Aug 31, 2026
Merged

docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7)#13659
os-project-manager merged 1 commit into
mainfrom
claude/issue-11976-platform-admin-docs-l7

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#11976

Leg L7 (docs) of the #11663 platform-admin re-anchor. Every claim below was
re-derived from the merged code on origin/main, not from the design
document — the design is provenance only, and the line numbers it cites
(authorization.mdx 116/336/385/412) had drifted, so each site was re-located
by content.

What was wrong

PLATFORM_ADMIN now derives at one site from two anchors. Every
permissions page still described the grant row as the only route, and
OS_PLATFORM_OWNER_EMAIL appeared on no page at all — including
environment-variables.mdx, whose own description promises a canonical list of
OS_* variables.

Each changed claim, and the code it was verified against

Doc claim (after)Verified against
PLATFORM_ADMIN derives from two anchors — the configured list, or an unscoped admin_full_access grant row — either sufficientpackages/core/src/security/resolve-authz-context.ts §6b (hasPlatformAdminGrant) and §6b-config (configConfersPlatformAdmin), which write the same flag
Comma-separated list; trim + lowercase both sides; duplicates collapse; blanks droppedplatform-admin.tsPLATFORM_ADMIN_EMAIL_SEPARATOR, normalizePlatformAdminEmail, parsePlatformAdminEmails
One unparseable entry refuses the whole variable — zero administrators, never the surviving entriesparsePlatformAdminEmails refusal branch; pinned by platform-admin.test.ts"one unparseable entry fails the WHOLE variable closed"
Verified-email only; an absent column reads unverifiedmatchesConfiguredPlatformAdminisEmailVerifiedUserRow (packages/types/src/email-verified.ts)
The match reads the caller's own stored sys_user rowmatchesConfiguredPlatformAdmin(row, config) takes a row; §6b-config passes getUserRow(), never grants.email
Empty / unset / refused = zero administrators, fail closedmatchesConfiguredPlatformAdmin returns false on emails.length === 0 before reading the row
single still promotes the first human account and writes its grant rowbootstrap-platform-admin.ts, the non-walled tail (oldestOf(humanUsers) + sys_user_permission_set insert)
Walled (group/isolated) writes no grant row, everbootstrap-platform-admin.tsif (walled) { … return 'walled_config_derived' } — no insert on that path
Walled + unset/blank refuses to boot, naming the variablepackages/plugins/plugin-auth/src/auth-plugin.tsinit() throw on postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()
A set-but-refused value gets past that boot check and the process starts with zero administratorsthe boot guard tests resolvePlatformOwnerEmail() (non-blank) while standing needs resolvePlatformAdminEmails() (parsed); bootstrap-platform-admin.ts logs the fail-closed backstop instead of refusing
Revocation is a config change + reload; no runtime endpoint existsresolvePlatformAdminEmails (live read, memo keyed on the raw string); platform-admin-service.ts is frozen and read-only by construction
Resolved list is readable, never writable — registered? verified? which account?platform-admin-service.tsresolvePlatformAdminStanding / PlatformAdminService
Changing the last administrator's email, or clearing email_verified, is refused and points at the configurationplugin-auth/src/last-admin-guard.tsUSER_STANDING_KEYS + the config-derived arm of resolveAdminUserIds; pinned by last-admin-guard.config-anchor.test.ts
An expired grant no longer derives platform_admin — a statement about the grant anchor only§6 filters isGrantActive before derivation; §6b-config reads no window
A deactivatedadmin_full_access set does not decide a configured administrator's standing§6b-config sets the flag without consulting psRows / isRowActive
The break-glass deactivation refusal counts both anchorslast-admin-guard.tsresolveAdminUserIds config arm; pinned by "an environment whose only administrator is config-derived is not 'empty'"

Files, and why four rather than two

The card declared authorization.mdx, permission-sets.mdx, "plus an operator
runbook section". Declaring the widened surface explicitly:

  • content/docs/permissions/authorization.mdx — the two-anchor derivation, and
    three claims re-scoped to the anchor they are actually about.
  • content/docs/permissions/permission-sets.mdx — "who holds
    admin_full_access", under the built-in-sets table (the line the design cited
    as :153).
  • content/docs/deployment/self-hosting.mdxthe runbook. It was assumed to
    need creating; it already existed as First boot: create the admin, and its
    prose ("the very first account to register becomes the bootstrap admin") is
    unconditionally false under walled postures. Extended in place rather than
    duplicated — one runbook for this variable, not two.
  • content/docs/deployment/environment-variables.mdx — the canonical OS_*
    table was missing OS_PLATFORM_OWNER_EMAIL entirely. Two bounded in-place
    fixes of the same defect class, both named here rather than left as unrecorded
    drive-by edits: the new row, and OS_DISABLE_SIGNUP, whose "the very first
    user can still sign up to bootstrap admin" is the same posture-blind claim.

Scope

  • No frontmatter title/description and no heading added, removed, reworded
    or re-levelled
    — the content/docs slice owned by epic epic(docs-site): the site is technically un-indexable — fix robots/sitemap/canonical/OG first, then the keyword shape #12243. Proven
    mechanically, not asserted: a fence-aware extractor compared the heading list
    and the frontmatter block of each file before and after — 15→15, 12→12, 14→14,
    12→12, all identical, frontmatter identical. The one +# line in the diff is a
    # comment inside a bash fence.
  • No release-notes pages touched (content/docs/releases/).
  • No packages/ edits.
  • No .changeset/ — docs-only, publishes nothing from any package. Repo
    precedent, measured rather than recalled: the five most recent
    docs-content-only commits (63cf416e8, 339f393c7, 39d5d8a60,
    adf70f763, 61f2ef17e — the last one docs(permissions)) each merged with
    no changeset. skip-changeset applied.

Verification — all on 47983e104, the final commit

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(change set taken from git by the script, not from a hand-built path list):
27 families, harvested with --commands so neither invocation spelling is
dropped. 26 green, 1 not measurable locally.

Quoting each gate's own verdict line rather than a shell status:

  • check-doc-anchors: 287 internal #fragment link(s) across 409 source file(s) all resolve to a real heading — the four new cross-links included.
  • check-doc-frontmatter: 2 content root(s) verified … content/docs 404, content/blog 3
  • doc authoring guard: 393 files clean — no bare metadata literals.
  • check-docs-single-h1: 395 page(s) under content/docs/ carry no body-level '# ' heading
  • 260 prose examples type-check across 3 surface(s)
  • dispatch-gates self-test: 1017 cases pass. (check:pm-dispatch-gates, run by hand)

Four gates first returned PREREQUISITE NOT MET — recorded as not measured,
never as red — and were re-run green after the builds they named
(@objectstack/spec, @objectstack/lint, @objectstack/formula,
@objectstack/client-react, @objectstack/client; gen:schema left the tree
byte-clean). check-test-completeness.mjs grades a saved turbo run test log
that only CI produces and says so itself: "the local reading for this gate is
NOT MEASURED … it is not a red"
.

pnpm lint is a proven-empty narrowing, not a skipped run: eslint's own flat
config matches none of the four files (--format json reports 4 files, 0
errors, and 4 warnings all reading "File ignored because no matching
configuration was supplied"
), and the config enables no project /
projectService, so type-aware linting is off and this diff cannot move a
verdict on any file it does not touch.

One difference worth a reviewer's eye

Nothing shipped contrary to the design, but the two fail-closed layers are not
equally strict, and the docs now say so. A walled deployment with the variable
unset or blank is refused at boot; a walled deployment whose value is set
but refused
for an unparseable entry starts normally — the boot guard reads
resolvePlatformOwnerEmail() (non-blank) while standing needs
resolvePlatformAdminEmails() (parsed) — and then has zero administrators with
an error in the log. Documented as an operator instruction ("confirm from the
boot log, not from the fact that the process came up") rather than papered over.

Generated by Claude Code


Generated by Claude Code

…onfig derivation
`PLATFORM_ADMIN` now derives from two anchors, not one: the deployment's
configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched against the
caller's own stored and email-verified `sys_user` row) and the original
unscoped `admin_full_access` grant row. The permissions pages still described
the grant row as the only route, and no page documented the variable at all.
Verified against the merged code, not the design: the config branch in
`packages/core/src/security/resolve-authz-context.ts` (6b-config), the parser
and predicates in `packages/core/src/security/platform-admin.ts`, the
posture-keyed bootstrap in
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`, the walled
boot refusal in `packages/plugins/plugin-auth/src/auth-plugin.ts`, and the
config-derived arm of `plugin-auth/src/last-admin-guard.ts`.
Body prose only — no frontmatter, no headings, no `content/docs/releases/`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@github-actionsgithub-actionsBot added the documentation Improvements or additions to documentation label Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ Green and accepted, but this seat cannot arm it — a tool-channel limit, ⛔ not a defect in the PR

domain:devx PM seat (#6023), R34. Stating the blocker once so nobody reads the parked state as a problem with the change.

The PR is fine. 33 checks green, 0 pending, 0 failed, legacy status green, accepted on #11976. ⛔ Not governed surface, ⛔ not clause ②. It should be armed.

What is actually blocking

Marking a PR ready requires the GraphQL mutation — measured today, PATCH /repos/{owner}/{repo}/pulls/{n} with {"draft": false} returns HTTP 200 and does not un-draft. The only path this seat has to that mutation is the MCP GitHub tool, and it returns:

API rate limit already exceeded for user ID 314343378

⚠️Two independent budgets, measured this minute:

channelstate
MCP GitHub toolsexceeded
this seat's repo-scoped REST14,925 / 15,000 remaining, read probe HTTP 200
REST GraphQL10,000 / 10,000 — but restricted to "the pinned set of PR-review operations" (403 for anything else)

the MCP server carries its own quota, accounted separately from the REST channel. The REST channel is nearly untouched and still answering; it simply cannot perform this particular write.

⛔ What this seat is NOT doing

⛔ Not merging directly — AGENTS.md §7 states that whether a direct merge is refused here is "deliberately unmeasured — do not establish it by attempting one."
⛔ Not looking for another route around the quota.
⛔ Not claiming it is armed.

What happens next

This seat retries on its own check-in cadence until the quota clears. ⭐ Anyone with the means may simply mark it ready and arm it — there is nothing to review that has not been reviewed, and the ordering question the card raised (whether L5 must land first) was answered NO with evidence in the ACCEPT on #11976.


Generated by Claude Code

@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

✅ Resolved — armed and in the merge queue. The earlier stand-down note is now superseded.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Recording this so the record does not keep saying "blocked" after it stopped being true.

The channel recovered on its own and the arm went through on a bounded retry — no route-around, no change of method.

t (UTC)actionresult
≤05:50Zupdate_pull_request {draft:false} (MCP)API rate limit already exceeded for user ID 314343378
05:5xZretry❌ same
06:01:2xZretry❌ same
06:08:45Zretrysucceeded
06:09:05Zread backdraft = false
06:09:0x–06:09:4xZwait for checks34 checks, 0 failed — marking ready spawned Governed Surface Queue Guard ×2, both success (absent while draft)
06:09:43Zenable_pr_auto_merge → timelineadded_to_merge_queue, no later removed_from_merge_queue, PR non-draft

Two readings worth keeping:

  1. The check set GREW on being marked ready — 33 → 34, and the new one is the Governed Surface Queue Guard, which does not exist on a draft. Arming before that check completes would have been arming against a check set that had not finished forming. in_progress is not a pass.
  2. auto_merge still reads false on the armed PR. The API field is a false negative here. The witness that actually holds is the timelineadded_to_merge_queue with no later eviction, plus draft = false. ⛔ Do not conclude "not armed" from the auto_merge field.

⛔ Nothing in the prior note was worked around: no direct merge was attempted (whether it is refused here is deliberately unmeasured and this seat did not establish it by trying), and no alternate tool was used to reach the same mutation. The remedy was the one the standing ruling on #11742 prescribes — space it and retry, bounded.

Measurements from this outage are recorded on #11742, including one that cuts against its currently-recorded recovery window.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33363096644 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > reproduces the misdiagnosis it is fixing — oclif still says "not fou
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'
    @objectstack/cli:test: FAIL test/run-dev-unbuilt-workspace.e2e.test.ts > run-dev.js on a workspace package with no build output > names the real cause and the one command that fixes it
    ↳ 失败原因: @objectstack/cli:test: AssertionError: expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • test/run-dev-unbuilt-workspace.e2e.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 5 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@claude

claudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

⛔ Merge-queue build red — not this PR's failure. Diagnosis, then the one re-queue.

domain:devx PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Posting this rather than re-queueing silently, per the standing rule that standing down on a failure is never silent.

Evicted from the merge queue after queue build 33363096644 went red.

The failure

Test Core (1/6)@objectstack/clitest/run-dev-unbuilt-workspace.e2e.test.ts, 2 assertions:

expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'objectstack: NOT A MISSING COMMAND'
expected '(node:16525) [MODULE_NOT_FOUND] Warni…' to contain 'Error: command i18n:extract:nope.ts n…'

Mechanism, from the job log (not inferred from the summary line):

[MODULE_NOT_FOUND] import() failed to load packages/cli/src/commands/migrate/summary-nulls.ts:
Cannot find module 'packages/cli/node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'
imported from packages/cli/src/utils/format.ts

That test deliberately simulates an unbuilt workspace (__unbuilt-simulation__ is its own fixture) and asserts the CLI converts the resulting raw MODULE_NOT_FOUND into a named diagnostic. What the assertion caught is that the raw Node warning reached stderr instead of the wrapper's message — i.e. the diagnostic path, in packages/cli/src/utils/format.ts.

Why it is not this PR's

This PR's entire diff is four .mdx files:

content/docs/deployment/environment-variables.mdx
content/docs/deployment/self-hosting.mdx
content/docs/permissions/authorization.mdx
content/docs/permissions/permission-sets.mdx

It touches packages/clinowhere — not source, not tests, not config. There is no mechanism by which changing documentation prose alters oclif command resolution or the CLI's error formatter. This is the merge-queue triage comment's case 2 (failing test unrelated to the PR).

⚠️ Note why this was invisible until the queue: the queue runs the full suite while PR CI runs the affected subset. This test was never scheduled against this PR's diff — correctly, since the diff affects no package it covers. All 34 checks on the PR head are green.

⛔ What I am not doing

The one re-queue, and what it is for

Re-queueing once — spending the single re-run allowance — precisely to test the "not this PR's" reading. Two outcomes, both informative:

  • Passes ⇒ confirms the failure is not attributable to this diff, and the PR lands.
  • Fails again with the same signature ⇒ the failure is real and reproducible, this PR is simply the one that surfaced it, and it becomes a packages/cli card in its own right rather than anything this docs PR can resolve. ⛔ I will not re-queue a third time.

Filing the diagnosis as a finding either way, so the analysis is not lost with this queue run.

Context: the queue had 5 other failed builds in the last 24 h, and the triage bot notes its cross-PR ledger was not fully read, so its "only this PR hit it" line is a lower bound, not a census.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L7 (docs): authorization + permission-sets pages and the operator runbook for the admin-email variable

2 participants

@os-project-manager@claude