Skip to content

docs(deployment): name both Connect-an-Agent doors in the OS_MCP_STDIO_API_KEY row - #18959

Merged
os-try-charles merged 1 commit into
mainfrom
claude/issue-18143-connect-agent-env-var-row
Sep 18, 2026
Merged

os-try-charles merged 1 commit into
mainfrom
claude/issue-18143-connect-agent-env-var-row

Conversation

@os-try-charles

@os-try-charles os-try-charles commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18143

Clause-②: no

The remainder — one line, one file

This card named four sites. PR #18573 landed three of them; content/docs/ai/connect-mcp.mdx belongs to #17648. What was left is the fourth: the OS_MCP_STDIO_API_KEY row in content/docs/deployment/environment-variables.mdx, located by content, not by the line number the card quotes.

the cell
before … Mint one from Setup → Connect an Agent (or POST /api/v1/keys). …
after … Mint one from the Connect an Agent page — Account → Developer for any signed-in user, Setup → Connect an Agent for platform admins — or POST /api/v1/keys. …

One line in, one line out. It is a table cell in a long Markdown table, so the two-door sentence is compressed to fit: pipe count unchanged (5), row count unchanged (133 OS_ rows), still a single line.

Why the old cell was wrong

SETUP_APP declares requiredPermissions: ['setup.access'], and a permissionless principal gets 403 PERMISSION_DENIED on /api/v1/meta/apps/setup. A direct minting instruction naming only the Setup door therefore tells a non-admin to take a path they cannot take. Ruling #16746 (decision batch #85) delivers the page to them through a navigationContributions entry in the account app — app account, group grp_account_developer (label Developer), item nav_connect_agent (label Connect an Agent), package id com.objectstack.account. The Setup entry stays for admins, deliberately.

So the fix is name both doors, ⛔ not replace Setup with Account — the shape PR #18142 and PR #18573 established. The wording here is copied from the two sibling pages rather than invented as a fourth spelling:

  • content/docs/api/index.mdx:68-69 — "…from the Connect an Agent page in the Console — Account → Developer for any signed-in user, Setup → Connect an Agent for platform admins."
  • content/docs/getting-started/build-with-claude-code.mdx:435-436 — "…lives on the Connect an Agent page: Account → Developer for any signed-in user, Setup → Connect an Agent for platform admins."

Post-condition probe — written BEFORE the edit, and deliberately NOT "Setup goes to 0"

An earlier round's first probe was "Setup → Connect an Agent must go to 0 in this file". That probe is wrong for this card: the correct end state keeps the Setup door named, so it would read a correct landing as a half-done one. The post-conditions here are about the Account door appearing alongside.

Every count is taken on a whitespace-flattened file, so wrapped prose cannot give a false zero, and every zero is paired with a control from the same population that must hit.

# reading (flattened) before after post-condition
A this file, Account → Developer 0 1 ≥ 1 — the Account door appears
B this file, Setup → Connect an Agent 1 1 ≥ 1 — Setup stays named, for admins
C CONTROL, this file, Connect an Agent unprefixed 1 2 nonzero both sides — the reader has a pulse
D table integrity: OS_ rows / pipes in the row / lines for that key 133 / 5 / 1 133 / 5 / 1 unchanged, single line
E CORPUS CONTROL over content/docs/**/*.mdx (404 files), Connect an Agent unprefixed 10 11 nonzero — the corpus reader has a pulse

Corpus-level close-out: the Setup door is still named in exactly 4 files (unchanged by design), and every one of the 4 now also names the Account door — carriers naming the Setup door but not the Account door: 0.

carrier Setup → Connect an Agent Account → Developer
content/docs/ai/connect-mcp.mdx 1 1
content/docs/api/index.mdx 1 1
content/docs/deployment/environment-variables.mdx 1 1
content/docs/getting-started/build-with-claude-code.mdx 1 1

Serial constraint — re-measured at hunk level, and it does not bite

PR #18420 (draft, untouched since 2026-09-17T16:16Z) is the only open PR touching this file. Read from its diff: its only hunk in this file is @@ -87,7 +87,7 @@, the OS_AUTOMATION_SCHEDULED_WORK_ENABLED row. This PR changes the row at :260. 173 lines apart, far outside git's three-line context ⇒ no textual conflict. Nothing in #18420 was touched or coordinated.

Verification

Gate families derived in this worktree from the real change set, not from a hand-written list: node scripts/pm/dispatch-gates.mjs --commands (change set: 1 path vs merge base 46559f61c).

  • 39 derived families, 39 run, all exit 0. Reconciled with exit codes recorded: dispatch-gates --repo objectstack-ai/objectstack --ran ⇒ "39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3)".
  • Four of them first returned PREREQUISITE NOT MET (exit 3 ×3, plus check:skill-examples exit 1 on an unbuilt client-react dist) — not findings. After turbo run build --filter=@objectstack/formula --filter=@objectstack/lint --filter=@objectstack/client-react --filter=@objectstack/client (exit 0) all four re-ran at exit 0: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift.
  • pnpm --filter @objectstack/spec build ran first (exit 0), so check:docs read a current tree.
  • That derivation is not a complete account of CI — the artifact-roster, wide-population, pending-changeset and path-scheduled families sit outside it, as the tool says of itself.
  • Control characters: grep -naP over the edited file finds none (exit 1), with a planted positive control proving the reader fires (exit 0, hit). pnpm check:nul-bytes exit 0.

pnpm lint — a proven narrowing, not a skipped run

The repo-wide scan is CI's run. Three pieces of evidence that narrowing excluded nothing:

  1. Population, read from eslint's own config: every files: glob in eslint.config.mjs enumerates code extensions (ts,tsx,mts,cts,js,jsx,mjs,cjs); the string mdx occurs 0 times in that config. .mdx is not in the linted population at all.
  2. File count, read from --format json: eslint over the changed file returns 0 results; the positive control (scripts/check-nul-bytes.mjs) returns 1 result — the reader resolves files and reports.
  3. Invariance for untouched files: the config enables no type-aware linting for any file (its own header: "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file"), so this diff cannot move any untouched file's verdict.

Changeset: skip-changeset, measured

Nothing published moves.

  • 83 tracked manifests; 70 declare files[] (the control: the reader resolves files[] arrays — e.g. @objectstack/spec ⇒ dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json). Entries reaching content/docs/**: 0.
  • Symbol grep over the 2138 files those files[] entries actually resolve to: Mint one from ⇒ 0, Account → Developer ⇒ 0; positive control objectstack ⇒ 1971 files, so the reader reaches published bytes.
  • The only consumer of content/docs/, @objectstack/docs (apps/docs), is private: true and declares no files[].
  • The one published manifest whose text mentions content/docs (@objectstack/plugin-webhooks) does so in its description prose about a different page; its files[] is dist, README.md, CHANGELOG.md.

Acceptance notes

Out of scope, noted and not filed:

  • The card's four deliberately excluded carriers (docs/adr/0101-…:104, docs/qa/platform-checklist/areas/ai.json:206, two .changeset/*.md) are dated records, left untouched.
  • This same file carries Setup → Settings and Setup → Authentication, and the corpus carries 27 other Setup → X phrases (Access Control, People, SSO Providers, Datasources, Approvals …). Those name genuinely admin-only surfaces addressed to admins — the Connect-an-Agent defect exists precisely because that one page is also delivered to non-admins through the account app, which is not true of the others. No defect, and the successor question has an answer: successor: none — no PR or reader is routed to them by this change.

Generated by Claude Code


Generated by Claude Code

…O_API_KEY row

The row told the reader to "Mint one from Setup -> Connect an Agent". SETUP_APP
declares requiredPermissions: ['setup.access'], so a permissionless principal gets
403 PERMISSION_DENIED on /api/v1/meta/apps/setup -- a direct minting instruction that
names only the Setup door sends a non-admin down a path they cannot take. The page is
delivered to them through a navigationContributions entry in the `account` app
instead, and the Setup entry stays for admins deliberately.

Name both doors, in the wording the sibling pages already use: Account -> Developer
for any signed-in user, Setup -> Connect an Agent for platform admins. Compressed to
one table cell; pipes and row count unchanged.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@os-try-charles os-try-charles added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@github-actions github-actions Bot added size/xs documentation Improvements or additions to documentation labels Sep 18, 2026
@os-try-charles
os-try-charles marked this pull request as ready for review September 18, 2026 07:55
@os-try-charles
os-try-charles added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit e19ae67 Sep 18, 2026
45 checks passed
@os-try-charles
os-try-charles deleted the claude/issue-18143-connect-agent-env-var-row branch September 18, 2026 08:11
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19117)

Fixes objectstack-ai#18263

Clause-②: no

## The defect, stated as it measures rather than as the title spells it

The card's title says "an entirely empty check-run output". Measured
later on the same card (comment `5705401851`, PR objectstack-ai#18524, run
`104955982460`), the failing `Check Changeset` answers `output.title` =
null with 0-byte `summary` and `text` and carries **`annotations_count`
= 1** — the runner's own generic exit-code annotation (`path .github`,
`level failure`, `title ''`, `message 'Process completed with exit code
1.'`). So the accurate description is **not** "an empty output"; it is
**"one generic annotation that states no cause"**.

That distinction is what makes this fix cheap. The annotation channel is
already open and already carried by this job, and a plain `run:` step
owns exactly one way onto it — the `::error::` workflow command, whose
sibling `::notice` this script has emitted for years. The gate's prose
refusals — among the best in the repo, naming the missing reading,
quoting the offending line and spelling the remedy down to "this red
clears with no push and no re-run" — reach the job log and are then
discarded at the check-run boundary. The reason is produced and thrown
away; the repair is to say it.

## What changed, entirely inside `scripts/check-changeset-no-major.mjs`

1. **Every near miss now renders its REASON.** `readClause2Line` has
always returned `spelling` / `inline-key` / `describing` beside the
offending line; this gate printed "a near miss" and dropped the reason.
It now prints the reason, the line, and the remedy that reason owes —
three different sentences, because `check-clause2-carriers.mjs` says in
its own words that "⛔ The reason changes the sentence, never the state".
2. **Every refusing lane emits exactly one `::error::` annotation**, so
the diagnosis crosses the check-run boundary onto the channel the run
already carries. Greens emit none.

The reason is **interpolated, never matched against a list held here**.
`CLAUSE2_NEAR_MISS_REMEDIES` is a lookup *from* whatever the reader
produced, and its miss path is loud: a reason this file has never been
taught still prints, still names the offending line, and still says
where the reason came from. That direction is deliberate — the reader is
a live surface (PR objectstack-ai#18903 is open on it, +225/-41), and a gate that
matched reasons against a frozen list would answer a new one with
exactly the silence this card is about.

Exactly one annotation per refusal, deliberately: a check run caps
annotations at ten per level, and this script has been past that cap
before (its stock-scoped predecessor emitted 171 `::notice` lines on PRs
that introduced none of them). And the annotation is **not** conditioned
on `GITHUB_ACTIONS` — `render` and `renderLevel` are pure by design,
which is what lets the self-test assert the MESSAGE rather than the exit
code, and an env read inside them would make the one thing this PR adds
the one thing the fixtures cannot see.

### What did NOT change

- **No `.github/workflows/**` file is touched.** The fix lives in the
`.mjs`, as the card asked.
- **Which bodies are ACCEPTED is unchanged.** `CLAUSE2_KEY_LINE` and
`readClause2Line` are not edited,
`scripts/pm/check-clause2-carriers.mjs` is not edited, and no verdict
moves. objectstack-ai#16303 remains open on the accept-set question and no arm of it
is implemented here. This PR makes the existing verdict legible and
nothing else.

## Post-condition 1 — the real predicate drives every row, with a
negative control that can fail

Every row below was produced by importing `readClause2Line` (the same
function the gate calls) and running the real `judgeLevel` /
`renderLevel`. ⛔ No hand-written matcher anywhere.

| body line | `readClause2Line` | exit | reason now in the emitted
annotation |
|---|---|---|---|
| `## Clause-②: no — …` (heading) | `near-miss` / `spelling` | 1 |
`spelling` + remedy |
| `` `Clause-②: no` · `skip-changeset` `` | `near-miss` / `describing` |
1 | `describing` + remedy |
| ``Domain: `domain:devx` · Clause-②: no`` | `near-miss` / `inline-key`
| 1 | `inline-key` + remedy |
| `Clause-②: no` (bare, own line) | `declared` / `no` | 0 | no
annotation — a green annotates nothing |
| NEGATIVE CONTROL `nothing here` | `null` | 1 | "the PR body carries no
`Clause-②:` line" — and no remedy, because there is no line to remedy |

The emitted text, for the heading shape (one line, escaped, abridged):

```
::error title=Check Changeset (level axis)%3A no readable `Clause-②%3A` declaration%2C and it is
the reading this PR needed::… %0A· declaration line: a near miss, not a declaration — reason
`spelling` — ## Clause-②: no — nothing published moves here%0A· remedy for `spelling`: the line
does not carry `Clause-②:` in the fixed spelling at the start of a line. The reader tolerates a
`- `, `* `, `> ` or `**` prefix and NOTHING else, so a markdown HEADING is a near miss and not a
declaration. Write it bare, on a line of its own.%0ADECLARE IT: …
```

## Post-condition 2 — a reason the code has no message for still prints

Driven through the exported
`nearMissReadings('a-reason-this-file-has-never-been-taught', …)`:

```
· declaration line: a near miss, not a declaration — reason
  `a-reason-this-file-has-never-been-taught` — ## Clause-②: no — the line that would otherwise be lost
· remedy for `a-reason-this-file-has-never-been-taught`: this gate carries no remedy sentence for a
  near miss of reason "a-reason-this-file-has-never-been-taught" — `readClause2Line`
  (scripts/pm/check-clause2-carriers.mjs) reports a reason this file has not been taught, and the
  reason plus the line are printed rather than swallowed. The offending line is: … Add the sentence
  for this reason to CLAUSE2_NEAR_MISS_REMEDIES in scripts/check-changeset-no-major.mjs.
```

Control: the same call with a KNOWN reason returns a different sentence,
so the miss path is not silently borrowing a known remedy — a wrong
prescription is worse than a named gap. `null` and `undefined` reasons
also return a sentence; an empty remedy would be this card's defect
moved one function along.

## Post-condition 3 — the two refusals, side by side

| refusal | where it is emitted | annotation |
|---|---|---|
| Clause-② declaration unreadable (near miss, or absent) | this script,
level axis | `::error title=Check Changeset (level axis)%3A no readable
`Clause-②%3A` declaration…` with the reason, the line and the remedy in
the message |
| declared `yes`, no moved package graded `minor`+ | this script, level
axis | `::error title=Check Changeset (level axis)%3A clause-② declares
YES while no moved package is graded `minor` or above…` |
| this PR adds no changeset | `pr-automation.yml`, `Require a changeset`
step, **unchanged by this PR** | `::error::This PR adds no changeset.
FIRST: …` |

A near miss and an absent line share a verdict (`not-measured-material`)
and used to share every byte anyone outside the run could read; they now
differ in the message, which the self-test pins. The missing-changeset
refusal is the workflow's own and already carried an `::error::`; the
self-test now **pins that it keeps one**, because without it that
refusal and this script's are once again one event from outside.

## Post-condition 4 — self-test and battery floor, before and after

| reading | before (`07c6f822e`) | after |
|---|---|---|
| `node scripts/check-changeset-no-major.mjs --self-test` | exit **0**,
299 assertions | exit **0**, 335 assertions |
| `SELF_TEST_BATTERY_FLOOR` (pinned roster size) | **18** | **19** |
| declared batteries | 18 | 19 |
| `'Missing input is a failure, never a pass (objectstack-ai#4690 / objectstack-ai#7006)'` floor |
**5** | **6** |

**Both floor moves are reported rather than absorbed, and neither is a
battery shrinking.**

- The roster grows by one because this PR declares one new battery,
`'objectstack-ai#18263: the refusal says its reason, and says it where the API can
read it'` (35 cases). The floor pin is the roster's own size, so
declaring a battery necessarily moves it; leaving it at 18 would let the
new battery be deleted later with nothing going red. The mechanism was
exercised in the process: the run before the roster entry existed failed
with *"registered 35 case(s) but is not declared in
SELF_TEST_BATTERIES"*.
- The `objectstack-ai#4690 / objectstack-ai#7006` battery goes 5 → 6 because one assertion there was
split into two. The old one was `render(unreadable).stdout.length ===
0`; what objectstack-ai#4690 forbids on stdout is a **tick**, and the `::error::`
annotation is the opposite of one, so the pin is now spelled as what it
always meant — every stdout line must start with `::error `, and there
must be exactly one of them. It is strictly stronger than the line it
replaces, not a relaxation.

## Reverse verification — three ablations, each proving the new battery
can fail

Each leg mutates the committed file, proves the mutation reached disk by
an anchor count, runs the self-test, then restores with `git checkout
HEAD -- …` and proves the restore by `git hash-object` against the HEAD
blob. A `trap … EXIT INT TERM` carries the restore on the crash path.
Predicted direction for all three: RED.

| leg | mutation | on-disk proof | self-test |
|---|---|---|---|
| A | delete the `::error::` annotation from the `not-measured-material`
lane | anchor `title: 'Check Changeset (level axis): no readable` 1 → 0
| **exit 1, 9 failures** |
| B | collapse the three near-miss remedies into one shared sentence |
anchor `Object.prototype.hasOwnProperty.call(CLAUSE2_NEAR_MISS_REMEDIES,
reason)` 1 → 0 | **exit 1, 3 failures** |
| C | make the unknown-reason path return an empty remedy | injected
marker 0 → 1, with the not-deleted control ` return (` held at 1 → 1 |
**exit 1, 2 failures** |

The failures name themselves. Leg A reds nine cases including *"a body
that ALMOST declared and a body that never tried produce different
text"*. Leg B reds *"the three near-miss reasons owe three DIFFERENT
remedies — one shared sentence would pass every assertion above while
reading no reason at all"*. Leg C reds *"a reason of `null` or
`undefined` still returns a sentence — an empty remedy is this card's
defect moved one function along"*.

**Leg C's first attempt was a proven no-op and its reading was
discarded, not retried quietly.** Its anchor was the text the mutation
inserts a copy of, so `grep -c` read 1 before and 1 after and the
harness refused to read a self-test result it could not prove had run.
It was re-run with an injected unique marker (0 → 1) and a control on
the text that must NOT vanish. The first attempt produced no reading at
all; the row above is the second.

Restores are proven, not assumed: each leg ends with `git checkout HEAD
-- …` (never a bare `git checkout --`, which would take the mutation
back out of the index) and then `git hash-object` against the HEAD blob
`af36b25de84a4e55c34ba323c83097c61a3f474c`, plus `git diff HEAD` empty
and the injected marker counted back to 0. All ran in a throwaway
detached worktree off this branch's commit, since the file under test is
the file the ablation mutates; that worktree is removed.

## Scope, changeset and labels

`skip-changeset`, measured rather than assumed. The diff is one file,
`scripts/check-changeset-no-major.mjs`. Resolving every tracked
`package.json` (83 tracked, 70 publishable — not private and carrying a
`files[]`) and asking which `files[]` entry would ship that path:
**zero**. Positive controls through the same resolver:
`packages/spec/dist/index.js` resolves to `@objectstack/spec`'s `dist`
entry and `packages/cli/dist/index.js` to `@objectstack/cli`'s, so the
resolver does find a shipped path when one exists;
`packages/spec/src/index.ts` correctly resolves to nothing. The
repo-root manifest is `private: true` with no `files`. Nothing published
moves, so this takes the label and not an empty changeset (workflow
route 2), and route 0 does not apply — this PR touches no
`.changeset/*.md` at all.

## One measured correction to the card's reproduction table

The card's table gives PR objectstack-ai#18959 as `` `Clause-②: no` `` on its own
line, backtick-wrapped, reading
`{"kind":"near-miss","reason":"describing"}`. Driven against
`origin/main` today, that exact line reads
**`{"kind":"declared","value":"no","arm":null}`** — a declaration, not a
near miss. `clause2LineDescribes`'s QUOTED-AND-CONTINUED tell fires only
when the backtick span opened at the key **continues past its closing
tick**; a span that closes with nothing after it is not describing. The
five PR bodies have all since been corrected, so the historical bytes
are no longer readable through the REST API and the exact line objectstack-ai#18959
carried could not be recovered — most likely it carried trailing content
after the closing tick, which is the `describing` shape and is covered
by the objectstack-ai#18946 row.

This narrows one row of the reproduction table; it moves nothing about
the card. Both near-miss spellings the card names are reproduced above
from the real reader, the third (`inline-key`) with them, and the remedy
is unchanged.

## Out of scope, noted here rather than filed

- `.github/workflows/pr-automation.yml` is untouched. Its `Require a
changeset` step's `::error::` is now **pinned** by this script's
self-test, which is a new coupling in the direction the card wants: it
is what keeps a missing-changeset refusal distinguishable from this
script's.
- The two other gates that run in the same job —
`check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` —
refuse to the job log with no annotation of their own, exactly as this
one did. Same shape, different surface, and out of this card's file
surface.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xs skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Four more shipped docs pages still send a non-admin to "Setup → Connect an Agent", the one app that 403s for them

2 participants