Skip to content

docs(approvals): unblock main — drop duplicated role warning, generalize approver literal - #3120

Closed
os-zhuang wants to merge 1 commit into
mainfrom
fix/docs-role-word-approvals-lint
Closed

os-zhuang wants to merge 1 commit into
mainfrom
fix/docs-role-word-approvals-lint

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Why

lint.yml has been red on main since #3113 — the Reserved-word ("role") docs ratchet step fails, so every open PR inherits it (e.g. #3116). Reproduces from a clean origin/main:

check-role-word: 1 problem(s)
  • content/docs/automation/approvals.mdx: role-word count grew 5 → 9. New occurrences are banned (ADR-0090 D3).

#3113 added four occurrences to content/docs/automation/approvals.mdx without ratcheting the baseline.

The decision: reword, not a baseline bump

Both obvious framings turned out to be wrong, so the evidence is worth stating.

"role is deprecated, so reword it away" — false. role is live and load-bearing:

  • packages/spec/src/automation/approval.zod.ts still enumerates 'role' alongside 'position' in ApproverType — they coexist and mean different things; neither is a migration half.
  • packages/spec/src/automation/approval.test.ts explicitly asserts 'role' parses.
  • expandRoleUsers resolves it against sys_member.role at runtime.
  • No deprecation marker anywhere on it.

role here is the better-auth org-membership tier (sys_member.role: owner/admin/member) — precisely ADR-0090 D3's single documented exception ("third-party schema we do not own"). #2738 (216fa9a) did not deprecate it; it added position beside it and documented role as "the membership tier it actually is".

"They're legitimate, so bump the baseline 5 → 9" — legitimate, but not necessary. Three of the four are a near-verbatim duplicate of the callout 80 lines earlier, and that earlier one is better (it names the approval-role-not-membership-tier lint rule). The fourth was an over-narrow API description. The baseline is for occurrences that can't be removed without losing accuracy — these can.

What changed

  • Removed the duplicated warning in the lifecycle Steps. Kept the fact that section actually needs (an approver entry resolving to nobody parks the run forever — the consequence) and cross-referenced the authoritative warning under 3. The approval node.
  • Generalized approverId: it was documented as accepting role:<r>. The runtime falls back to a generic `${a.type}:${a.value}` literal for every approver type (expandApprovers), and rest-server.ts only splits on commas — it never validates the prefix. <type>:<value> is the real contract; role:<r> described one arbitrary instance of it.

Net: same information, one authoritative home, no accuracy lost, and the reserved-word surface shrinks back to 5 instead of freezing 4 avoidable occurrences. The surviving 5 are the one better-auth boundary callout — deliberately untouched, since renaming a third-party identifier we don't own would make the docs wrong.

Verification

$ node scripts/check-role-word.mjs
check-role-word: OK (49 baselined file(s), no new occurrences).

scripts/role-word-baseline.json is unmodified — the diff is one file. The sibling lint.yml doc gates also pass locally:

  • check:doc-authoring → 199 files clean
  • check:release-notes → OK

Notes for the reviewer

  • Docs-only → skip-changeset.
  • ⚠️ There is an uncommitted, unpushed draft of the same fix in a sibling worktree on branch fix/role-word-approvals (no PR opened). It reaches a similar conclusion but cross-references a broken anchor (#the-approval-node; the heading is ### 3. The approval node). If that agent is still active, close whichever of these lands second.

🤖 Generated with Claude Code

…teral (#3113 lint fix)

`lint.yml` has been red on main since #3113: the reserved-word ratchet
(ADR-0090 D3) caught content/docs/automation/approvals.mdx growing 5 → 9
occurrences of "role". Every open PR inherits the failure.

The four new occurrences are NOT a fifth legitimate boundary needing a
baseline bump — three of them are a near-verbatim duplicate of the callout
80 lines earlier, and the fourth is an over-narrow API description:

- The lifecycle Steps repeated the "type: 'role' is not a position" warning
  that "3. The approval node" already carries (and carries better — it names
  the `approval-role-not-membership-tier` lint rule). Kept the fact the
  lifecycle section actually needs (an unresolved approver list parks the run
  forever) and cross-referenced the authoritative warning instead.
- `approverId` was documented as accepting `role:<r>`. The runtime falls back
  to a generic `` `${a.type}:${a.value}` `` literal for EVERY approver type
  (approval-service.ts expandApprovers), and the REST layer only splits on
  commas — it never validates the prefix. `<type>:<value>` is the accurate
  contract; `role:<r>` described one arbitrary instance of it.

Net: same information, one authoritative home, no accuracy lost, and the
baseline stays at 5 rather than freezing 4 avoidable occurrences.

Deliberately NOT rewording the surviving 5: `role` there is the better-auth
org-membership tier (`sys_member.role`), which is D3's single documented
exception. It is live, not deprecated — spec `ApproverType` still enumerates
'role' alongside 'position' (approval.zod.ts, asserted in approval.test.ts)
and `expandRoleUsers` resolves it against `sys_member.role`. Renaming a
third-party identifier we do not own would make the docs wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@os-zhuang os-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Jul 17, 2026
@vercel

vercel Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 17, 2026 12:14pm

Request Review

@os-zhuang

Copy link
Copy Markdown
Contributor Author

关闭:与 #3122 重复,且两者都是创可贴——只把文档里的重复表述删掉,没碰造成混淆的根因(ApproverType.role 这个名字本身)。

改为按 #3133 的结论一次做对:把 role 改名成 ADR 早已规定的投影名 org_membership_level,文档随之不再需要写这个保留词,role-word 棘轮的红是改名的副产品而不是单独一轮修补。新 PR 会引用本 PR 与 #3122。

@os-zhuang os-zhuang closed this Jul 17, 2026
@os-zhuang
os-zhuang deleted the fix/docs-role-word-approvals-lint branch July 17, 2026 12:22
xuyushun441-sys pushed a commit that referenced this pull request Jul 17, 2026
…l` (#3133)

`ApproverType.role` was the last platform surface projecting the word
ADR-0090 D3 reserved. Renaming it also unbreaks `lint.yml` on main: the
role-word ratchet has been red since #3113 (approvals.mdx 5 → 9), and the
docs stop needing the word once the type is spelled correctly.

D3's exception does not cover this enum. It protects better-auth's own
`sys_member.role` COLUMN — third-party schema we cannot rename. `ApproverType`
is ours: an authoring surface, i.e. the *projection*, which D3 says is spelled
`org_membership_level` and labelled "organization membership", never "role".

The sentence licensing the leak is itself false. ADR-0090 D3:203 claims
`sys_member.role` is "already relabelled `org_membership_level` in the platform
projection (ADR-0057 D7)" — but `org_membership_level` appeared nowhere in the
codebase (one comment in position.zod.ts), and ADR-0057 D7:335 lists that
relabel under "Deferred (evidence-gated, P4)". The projection never landed, so
the word reached authors.

The name manufactured a silent failure ("hotcrm class"): every sibling surface
renamed to `position` (`sys_role`, `ShareRecipientType.role`, `ctx.roles[]`),
so `{ type: 'role', value: 'sales_manager' }` reads as a position's legacy
spelling. It resolves against the membership tier, finds no member row, falls
back to an inert `role:sales_manager` literal, and the request waits forever.
Repo-wide, `type: 'role'` had ZERO real callers — only lint tests and the docs
warning that exists to undo the confusion the name creates.

- spec: `ApproverType` gains `org_membership_level`; `role` kept as a
  deprecated alias for one window so a published 15.x flow keeps loading.
  `DEPRECATED_APPROVER_TYPES` + `canonicalApproverType()` are the single
  source for the mapping (runtime and lint both read it).
- plugin-approvals: resolves on the canonical type, warns on the deprecated
  spelling, `expandRoleUsers` → `expandMembershipTierUsers`. The `type:value`
  fallback literal deliberately keeps the AUTHORED spelling — 15.x wrote
  `role:<v>` into `sys_approval_approver` / `pending_approvers`, and
  canonicalising it here would orphan every stored slot.
- lint: `approval-role-not-membership-tier` →
  `approval-approver-not-membership-tier` (the rule id carried the word too),
  plus `approval-approver-type-deprecated`. Mutually exclusive: a bad VALUE
  wins, because prescribing `org_membership_level` for a position name is
  wrong advice — the fix there is `position`.
- docs/skill/reference + role-word baseline ratcheted DOWN (approvals.mdx
  5 → 1, automation SKILL.md 3 → 1); api-surface snapshot regenerated
  (0 breaking, 2 added).

Studio still offers "Role" and its picker calls `client.list('role')` on a
metadata type D3 deleted — that picker is already dead, and the dropdown is
objectui's own hardcoded copy of this enum. Tracked as objectui follow-up in
degrades to free text (strictly better than a picker that lists nothing).

Closes #3133. Supersedes #3120 and #3122, which only deleted the duplicated
docs callout without touching the name that causes the confusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
xuyushun441-sys pushed a commit that referenced this pull request Jul 17, 2026
…l` (#3133)

`ApproverType.role` was the last platform surface projecting the word
ADR-0090 D3 reserved. Renaming it also unbreaks `lint.yml` on main: the
role-word ratchet has been red since #3113 (approvals.mdx 5 → 9), and the
docs stop needing the word once the type is spelled correctly.

D3's exception does not cover this enum. It protects better-auth's own
`sys_member.role` COLUMN — third-party schema we cannot rename. `ApproverType`
is ours: an authoring surface, i.e. the *projection*, which D3 says is spelled
`org_membership_level` and labelled "organization membership", never "role".

The sentence licensing the leak is itself false. ADR-0090 D3:203 claims
`sys_member.role` is "already relabelled `org_membership_level` in the platform
projection (ADR-0057 D7)" — but `org_membership_level` appeared nowhere in the
codebase (one comment in position.zod.ts), and ADR-0057 D7:335 lists that
relabel under "Deferred (evidence-gated, P4)". The projection never landed, so
the word reached authors.

The name manufactured a silent failure ("hotcrm class"): every sibling surface
renamed to `position` (`sys_role`, `ShareRecipientType.role`, `ctx.roles[]`),
so `{ type: 'role', value: 'sales_manager' }` reads as a position's legacy
spelling. It resolves against the membership tier, finds no member row, falls
back to an inert `role:sales_manager` literal, and the request waits forever.
Repo-wide, `type: 'role'` had ZERO real callers — only lint tests and the docs
warning that exists to undo the confusion the name creates.

- spec: `ApproverType` gains `org_membership_level`; `role` kept as a
  deprecated alias for one window so a published 15.x flow keeps loading.
  `DEPRECATED_APPROVER_TYPES` + `canonicalApproverType()` are the single
  source for the mapping (runtime and lint both read it).
- plugin-approvals: resolves on the canonical type, warns on the deprecated
  spelling, `expandRoleUsers` → `expandMembershipTierUsers`. The `type:value`
  fallback literal deliberately keeps the AUTHORED spelling — 15.x wrote
  `role:<v>` into `sys_approval_approver` / `pending_approvers`, and
  canonicalising it here would orphan every stored slot.
- lint: `approval-role-not-membership-tier` →
  `approval-approver-not-membership-tier` (the rule id carried the word too),
  plus `approval-approver-type-deprecated`. Mutually exclusive: a bad VALUE
  wins, because prescribing `org_membership_level` for a position name is
  wrong advice — the fix there is `position`.
- docs/skill/reference + role-word baseline ratcheted DOWN (approvals.mdx
  5 → 1, automation SKILL.md 3 → 1); api-surface snapshot regenerated
  (0 breaking, 2 added).

Studio still offers "Role" and its picker calls `client.list('role')` on a
metadata type D3 deleted — that picker is already dead, and the dropdown is
objectui's own hardcoded copy of this enum. Tracked as objectui follow-up in
degrades to free text (strictly better than a picker that lists nothing).

Closes #3133. Supersedes #3120 and #3122, which only deleted the duplicated
docs callout without touching the name that causes the confusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 17, 2026
…l` (#3137)

Closes #3133. Renames the last platform surface projecting the ADR-0090 D3 reserved word: `ApproverType.role` → `org_membership_level`, with `role` kept as a deprecated alias for one window (resolves identically, warns). Publishes `xEnumDeprecated` on the node configSchema so Studio drops the deprecated spelling from the approver-type picker. Supersedes #3120/#3122. objectui half: objectstack-ai/objectui#2643 (merged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…kages/create-objectstack/src to the commits that decided them (objectstack-ai#20748)

Part of objectstack-ai#20594
Clause-②: no

## What changed

This is stage 11 of the `domain:cli` lane of the dead-citation sweep:
`packages/create-objectstack/src`. Every comment site there that cited a
tracker number answering 404 now cites, in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), the commit in this repository's history that
decided what the line describes, and keeps saying in its own words what
that commit decided. PR objectstack-ai#20533 is the method, and stages 1 to 10 of this
card (PR objectstack-ai#20624, PR objectstack-ai#20632, PR objectstack-ai#20656, PR objectstack-ai#20673, PR objectstack-ai#20689, PR objectstack-ai#20703,
PR objectstack-ai#20713, PR objectstack-ai#20723, PR objectstack-ai#20735, PR objectstack-ai#20741) are the precedents. The card
stays open for the lane's remaining packages, so this PR says `Part of`.

That is **25 sites on 25 lines in 10 files, covering 9 numbers**,
rewritten to **8 distinct commits**:
- the census's **3 sites**: `src/banner.ts` 2, `src/index.ts` 1 (2
numbers);
- **22 test-file comment sites** in 8 test files (the census defers
`*.test.ts`; stages 1 to 10 took test comments too):
`starter-comments-self-contained.test.ts` 9,
`scaffold-e2e-boot-probe.test.ts` 3, `banner-version.test.ts` 2,
`blank-readme-validate-disclosure.test.ts` 2,
`scaffold-next-steps-pm.test.ts` 2, `template-consistency.test.ts` 2,
`scaffold-skills-single-copy.test.ts` 1, `template-ci-workflow.test.ts`
1.

Only comments changed: **25 lines out, 25 in**, every one of them a site
(no companion line), and every touched file keeps its line count (147 /
67 / 58 / 617 / 910 / 261 / 357 / 328 / 221 / 745), so no line citation
into these files moves. **No citation number is added**: the added lines
carry no tracker number at all, and no PR number stands on an added
line. No ADR or ruling-record file in `docs/adr/` or
`scripts/adr-anchors/` records any of these 9 decisions (a grep for the
9 numbers there reads 0 hits; the control number `objectstack-ai#7329` reads 2 in the
same tree), so every anchor is a commit.

**No changeset; `skip-changeset`.** The rewritten comments do not reach
the published `dist` (measured below), as in stages 5 and 7 (PR objectstack-ai#20689,
PR objectstack-ai#20713).

**Scaffold output is untouched.** No site sits inside a template literal
or in a file the scaffolder copies: `src/templates/**` carries zero
tracker citations in either projection, and all 25 sites are `//` or
JSDoc comment prose outside any string. A real scaffold run at base and
at head emits a byte-identical project (below).

## Census: `packages/create-objectstack`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run under `with-fleet.sh
--read` for the token. The count is its `allocated-but-absent` findings
under `packages/create-objectstack/`. Both runs enumerated the whole
board.

| reading | tree | board | whole-repo `allocated-but-absent` | package
sites | lines | numbers | files |
|---|---|---|---|---|---|---|---|
| before | base `01e78dceef`, run 2026-09-30T01:14:08Z to 01:19:48Z |
enumerated, 186 pages, frontier objectstack-ai#20742, 18,569 numbers | 1,077 | **3** |
3 | 2 | 2 |
| after | `4ed638093d`, run 01:30:22Z to 01:35:31Z | enumerated, 186
pages, frontier objectstack-ai#20745, 18,572 numbers | 1,074 | **0** | 0 | 0 | 0 |

The whole-repo drop of 3 is exactly these sites: a site-by-site diff of
the two JSON outputs has 3 findings gone (`banner.ts:10`,
`banner.ts:17`, `index.ts:441`) and none added. The other three tallies
(`resolves` 33,014, `resolves-as-pull-request` 1,984,
`cross-repo-unjudged` 995) are equal in both runs.
`packages/create-objectstack` is byte-identical at `4ed638093d` and at
the head (the one merge brought no file under it).

**Supplementary scan (test files, strings and files outside `src/`
included).** The gate's exported `extractCitations` and
`classifyCitation` over all 53 tracked files of the package
(`CHANGELOG.md` excluded), comment-prose and whole-file projections,
with the board from the gate's own `probeBoard`: 77 citations and 33
dead before, 52 and 8 after. Under `src/`: comments 3 dead to 0, test
comments 23 to 1, test strings 7 unchanged; `src/templates/**` 0
citations of any kind. Outside `src/`, one citation
(`vitest.config.ts:24`, `objectstack-ai#10374`) answers 200. Its before list of `src/`
comment sites equals the census's. The 8 left are 7 test strings and 1
test comment with no deciding commit (see "The site left" and Acceptance
notes).

## Per-number table

`git blame` at the base ties each line to the commit that wrote it, and
each anchor was read in its message, changeset or diff, not only its
subject.

| number | sites (base line) | anchor: what it decided |
|---|---|---|
| `objectstack-ai#10325` | `banner.ts:10`; `banner-version.test.ts:3` | `cec9d239d`:
the startup banner reads the real version from `package.json` through
the new `renderVersionBanner()`, and sizes the box from the version's
plain length, widening and never truncating, instead of the hardcoded
`v6.x`. Both lines blame to it; its message carries the closing trailer
for this number. New anchor. |
| `objectstack-ai#10322` | `banner.ts:17`; `index.ts:441`;
`banner-version.test.ts:17`;
`blank-readme-validate-disclosure.test.ts:3`, `:50`;
`scaffold-next-steps-pm.test.ts:3`, `:7` | `8d21f7a76`: detect the
package manager once, up front, and name it in the install line, the
install-failure remedy and every "Next steps" line (labels padded to the
longer of the two instead of hand-kerned for `npm`), and name `validate`
in the blank README's "Getting started". Its message carries the closing
trailer for this number. `index.ts:441` and the two test headers blame
to it; `banner.ts:17` and `banner-version.test.ts:17` blame to
`cec9d239d`, whose message calls this "the sibling bug fixed one
function away in the same file"; `scaffold-next-steps-pm.test.ts:7`
blames to `c6c7feccd`, a re-wrap that keeps the sentence. New anchor. |
| `objectstack-ai#19424` | `scaffold-e2e-boot-probe.test.ts:397`, `:679`, `:816` |
`c27e16059`: the boot-probe neighbour announces its own listener (or its
bind error), asks the kernel for its port with `listen(0)`, and the
harness names five distinct outcomes instead of one "never came up"; the
controls block pins each. All three lines blame to it; its message
carries the closing trailer for this number. New anchor. |
| `objectstack-ai#16331` | `scaffold-skills-single-copy.test.ts:3` | `fd75728bc`:
install the skills bundle for one agent (`--skill '*' --agent
claude-code -y`) so a scaffolded project's first commit stages it once,
with no symlinks. The line blames to it, and its diff is what added the
number; its message names none. New anchor. |
| `objectstack-ai#10990` | `starter-comments-self-contained.test.ts:41`, `:283` |
`21756b325`: converge the shipped template files on the ruled canonical
docs origin and pin that convergence as assertion 4 over
`shippedFiles()`. Both lines blame to it; its message carries the
closing trailer for this number. New anchor for this number. |
| `objectstack-ai#11022` | `starter-comments-self-contained.test.ts:50`, `:91`,
`:122`, `:221` | `21756b325`: rewrite the blank README's two
monorepo-only references, add the fifth `MONOREPO_ONLY` pattern (the
framework's own name next to a "repo" word), retire the self-retiring
`EXCLUDED` entry and add the README's two RATIONALE facts. All four
lines blame to it. Stage 3 (PR objectstack-ai#20656) gave this number the same anchor.
|
| `objectstack-ai#15150` | `starter-comments-self-contained.test.ts:72`, `:133`,
`:141` | `cc986c913`: the sixth `MONOREPO_ONLY` pattern, for a reference
written as a relative path that climbs out of the project, anchored on
bare `../` rather than on a depth judgement. All three lines blame to
it; its diff is what added the number (8 times, across both scaffolders'
pins), its message names none. New anchor. |
| `objectstack-ai#16330` | `template-ci-workflow.test.ts:3`;
`template-consistency.test.ts:376` | `4998efa71`: ship
`.github/workflows/ci.yml` in the blank template (the template's first
dot-directory) so a scaffolded project has gates from its first push.
Both lines blame to it; its diff added the number, its message names
none. New anchor. |
| `objectstack-ai#10326` | `template-consistency.test.ts:498` | `675ab574e`: declare
the two benign peer skews a clean first install reported as scoped pnpm
`allowedVersions` inside the scaffold. The line blames to it. Stage 3
(PR objectstack-ai#20656) gave this number the same anchor. |

**Anchor checks.** Every cited sha matches exactly one object (`git
rev-parse --disambiguate`, count 1 for each of the 8), is a commit, has
one parent, and is an ancestor of `main` (`merge-base --is-ancestor`
against `01e78dceef`, exit 0 for all 8). The checkout is not shallow.
The control leg `2aca1bc4c0` (the parent of the oldest anchor
`675ab574e`, 2026-08-20) exits 0 against the base, and the negative
control (the base as an ancestor of `675ab574e`) exits 1. Two anchors
reuse the landed stages' (`21756b325`, `675ab574e`); six are new.

**Numbers.** All 9 dropped numbers answer 404 by REST (probed
2026-09-30T01:11:14Z and again at 01:50:21Z). The one number kept on a
line beside the changed ones, `objectstack-ai#9779`
(`scaffold-e2e-boot-probe.test.ts:673`), answers 200. The anchor
commits' own PR numbers are not cited: three of them (objectstack-ai#11030, objectstack-ai#11013,
objectstack-ai#11191) answer 404 as well, which is the reason the ruling cites
commits.

## The site left

**No deciding commit (1 site, a test comment, so not in the census):**
`template-consistency.test.ts:153` (`objectstack-ai#11048`): "admitting them is a
support decision (objectstack-ai#11048), not a value to drift here". The number names
an open support decision (whether to admit pnpm 10.0 to 10.4). The only
commit naming it, `568de194e`, files it unassigned; no later commit
decides it, and the floor is still pnpm 10.15 or later at the base.
Stage 3 (PR objectstack-ai#20656) left the sibling site
`packages/cli/src/commands/init.ts:267` for the same reason.

## Mechanical guard: no code token moves, and nothing emitted moves

**H2 holds on both readings: the parser-token diff is empty, and the
emitted `dist` and the scaffolded project are byte-identical.**

**Token guard.** It compares the TypeScript parser's leaf tokens
(TypeScript 6.0.3, `getChildren` walk, JSDoc nodes excluded) of the 10
touched files at base `01e78dceef` and at `4ed638093d`. Controls mutate
the head text in memory only.
- Real run: 16,198 base tokens, 0 files differing, exit 0.
- Comment-insertion control: 0 differing, exit 0.
- Code-insertion control: all 10 files differ, exit 1.
- String control (the first character of the first import specifier
flipped in each file): all 10 files differ, first differing kind
`StringLiteral`, exit 1.

All 50 changed lines (25 out, 25 in) are `//` or `*` comment lines.

**Emitted `dist`.** `pnpm --filter create-objectstack build` at base
(before any edit) and at `4ed638093d`, after the same dependency build.
All 24 `dist` files (`index.js`, `chunk-ZIUW7UEA.js`,
`created-summary.js`, `created-summary.d.ts` and the 20 copied template
files) have equal sha256 at base and head, and `diff -r` is empty. None
of the dead numbers appears in the base `dist` at all: tsup drops these
comments.
- Code-mutation control (`scripts/ablation-replace.mjs`, wrap mode,
anchor `Dependency installation failed.` hit 1 to 0, planted marker 0 to
1, blob `b68538942c96` to `860de8778f10`;
`scripts/ablation-dist-preflight.mjs` found the marker in
`dist/index.js`): `index.js` differs from the head build. The blob was
restored to HEAD `b68538942c96` with `git diff HEAD` empty, `dist` was
rebuilt, the preflight in `--absent` mode reads the marker absent from
all 24 files with a clean tree, and the 24 sha256 values equal the first
head build.
- The whole-workspace builds (below) left `create-objectstack`'s `dist`
equal to the same 24 values.

**Scaffold output.** `node
packages/create-objectstack/bin/create-objectstack.js demo-app
--skip-install --skip-skills`, run in an empty directory from the base
build and again from the head build: both emit the same 21 files with
equal sha256, `diff -r` is empty, and the printed output differs only in
the absolute target directory line.

A raw scan of the 10 changed files for ASCII control bytes finds none (a
positive probe on a scratch file with one such byte reads 1), and
`check:nul-bytes` exits 0.

## Changeset

**None; `skip-changeset`.** The package's `files[]` is `dist`,
`README.md` and `CHANGELOG.md`; the build above emits a byte-identical
`dist` at base and head, and the code-mutation control proves that build
does move when code moves. The two other shipped files are untouched, so
this diff publishes nothing.

## Gates (head `a84b73af13`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its official wording, verbatim (printed by every run; the
command line differs per run and is listed in the verdicts below):

> **Declared narrowing — verification ran UNLOCKED.**
`scripts/pm/os-verify-lock.sh`
> could not take the shared verify lock on this host: no usable `flock`.
The shared
> verify lock is declared Linux-only (`flock` is util-linux, and a stock
macOS does
> not ship it), so the command below was run directly, without the lock
—
> a declared narrowing, not a silent one. No serialization guarantee
held for this
> run, nor for any sibling agent in this container while it ran.

Its verdict line from each run (the closure build and the base build at
`01e78dceef`; the head build, the first whole-workspace build, the
tests, the boot-probe file and the typecheck at `4ed638093d`; the second
whole-workspace build, tests and typecheck at this head after the
merge):

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 22s · declare it in the PR body · pnpm --workspace-concurrency=2 --filter 'create-objectstack^...' build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter create-objectstack build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 1s · declare it in the PR body · pnpm --filter create-objectstack build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 134s (2m14s) · declare it in the PR body · pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/* --concurrency=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 8s · declare it in the PR body · pnpm --filter create-objectstack exec vitest run --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter create-objectstack exec vitest run --maxWorkers=2 src/scaffold-e2e-boot-probe.test.ts
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter create-objectstack typecheck
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 35s · declare it in the PR body · pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/* --concurrency=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 7s · declare it in the PR body · pnpm --filter create-objectstack exec vitest run --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter create-objectstack typecheck
```

- **Build:** `create-objectstack`'s dependency closure
(`@objectstack/spec`, its only workspace dependency), then the package,
then the whole workspace, `turbo run build --filter=./packages/*
--filter=./packages/*/* --concurrency=2`, 71 of 71 tasks, before and
again after the merge. The tree was clean after each.
- **Tests:** `vitest run --maxWorkers=2`: 16 files, 247 tests: 233
passed and 14 skipped, at this head and at `4ed638093d`. The 14 skipped
are the whole of `scaffold-e2e-boot-probe.test.ts` (run alone: 1 file
skipped, 14 tests skipped), which its own `RUNNABLE` gate
(`process.platform === 'linux'`, plus `bash`, `curl`, `openssl`) skips
on this macOS host. **NOT MEASURED locally:
`scaffold-e2e-boot-probe.test.ts`, reason: Linux-only by its own gate;
CI runs it.** Its diff is 3 comment lines with identical parser tokens.
- **Typecheck:** `pnpm --filter create-objectstack typecheck` (`tsc
--noEmit`) exits 0 at this head and at `4ed638093d`. `--listFiles`
reaches 26 `src/` files outside `src/templates/`, including all 16 tests
and all 10 touched files.
- **Spec artifacts:** not run. Neither `origin/main`'s one incoming
commit nor this diff touches `packages/spec`.
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at this head (2026-09-30T02:00:09Z to 02:00:43Z), and at
`4ed638093d` (01:49:31Z to 01:50:04Z).
- **Citation judging:** after merging `origin/main` (`697845d19f`),
`node scripts/check-issue-citations.mjs --base origin/main` reports "no
issue citations added against 697845d (2 file(s) read)" (exit 0).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 52 families, the same
list at `4ed638093d` and at this head. All 52 exit 0 at this head in one
pass, and `--ran` with the exit-coded record reads "52 derived, 52 run,
0 NOT-MEASURED, 0 UNRUN" (a derived zero). Among them:
`check:issue-citations`, `check:doc-authoring`, `check:nul-bytes`,
`check:published-files`, `check:cross-package-test-inputs`,
`check:dts-closure`, `check:dual-build-cjs-loads`,
`check:type-check-debt`, `check-changeset-no-major`.
- **Artifact rosters:** 36 of the 39 non-self-test roster rows exit 0 at
this head, among them `check:scaffold-emission-policy` and the three the
derivation marks as keeping their roster under one of this diff's paths
(`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`). The other three need a pull request's
context; they are run against this PR once it exists and reported on the
card. The 18 self-test-only rows grade their checkers' fixtures and
cannot judge this diff.

## Hypotheses (measured first)

- **H0 holds.** At base `01e78dceef` the filtered census answers 3 sites
on 3 lines, 2 numbers, 2 files, as on the seat's `0be898499f`. The
whole-repo count is 1,077.
- **H1 holds.** After the rewrite, the filtered census answers 0 for
`packages/create-objectstack`. No census site was left for an open PR
(the file lists of all open PRs were read at 2026-09-30T01:21:44Z and
again at 01:52:53Z, 8 PRs each time: only the Version Packages PR objectstack-ai#20639
touches the package, in `CHANGELOG.md` and `package.json`) or for an
unfound anchor. The one site left for an unfound anchor is a test
comment, outside the census.
- **H2 holds.** The parser leaf-token diff of all 10 touched files is
empty with its controls firing, and, independently, the emitted `dist`
and the scaffolded project are byte-identical at base and head, with a
code-mutation control that changes `dist`.

## Acceptance notes

- **Strings, the form-D stage.** Seven dead numbers remain in string
literals, all test titles in `src/`: `banner-version.test.ts:66` and
`:96` (`objectstack-ai#10325`), `blank-readme-validate-disclosure.test.ts:25`
(`objectstack-ai#10322`), `scaffold-e2e-boot-probe.test.ts:829` (`objectstack-ai#19424`),
`scaffold-next-steps-pm.test.ts:173` and `:197` (`objectstack-ai#10322`),
`template-consistency.test.ts:503` (`objectstack-ai#10326`). They stay on the card for
its form-D stage; no string moved here. None is an assertion text or
scaffold output.
- **Outside `src/**`:** nothing dead. The one citation there,
`vitest.config.ts:24` (`objectstack-ai#10374`), answers 200; `README.md` and `bin/`
carry none.
- **Live but misdirected numbers, a different class.** Two numbers in
this package answer 200, but as unrelated pull requests. `objectstack-ai#4902`
(`index.ts:165`, `:239`; `rewrite-identity.ts:36`;
`runtime-image.ts:140`; `rewrite-identity.test.ts:3`, and the test title
at `:123`) was written by `8d41998b0`, whose own message names `objectstack-ai#4926`
(the remote-template object-name rewrite being silently skipped), and
`f2f09e4e3` repeated it at `runtime-image.ts:140`; `objectstack-ai#4902` itself is an
unrelated `init-service` guard PR. `objectstack-ai#3120` (`template-copy.ts:20`;
`template-consistency.test.ts:259`) was written by `3b6ef8a32` (the
scaffolded `.gitignore`), and `objectstack-ai#3120` is an unrelated approvals-docs PR.
The census reads both as `resolves-as-pull-request`, a reading and not a
finding, and this card is about 404s, so neither moved here. Noted, not
filed.
- **Card-word residue, cited nowhere.** Some rewritten test headers
still say "the card" or "per triage" nearby
(`banner-version.test.ts:13`,
`blank-readme-validate-disclosure.test.ts:3`). They cite no dead number,
so they were left, as the landed stages left theirs.
- **The moving `origin/main`.** The branch merged `origin/main` once
(`a84b73af13`, merging `697845d19f`: PR objectstack-ai#20742, the `service-package`
citation re-anchoring). Nothing under `packages/create-objectstack` or
`packages/spec` changed, so the package's tests, typecheck, every
derived gate, the roster rows and lint were rerun at the merge head and
all read as before.

## Deviations

- **Three derived gates first read NOT MEASURED.**
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` exited 3 (PREREQUISITE NOT MET: built output
absent) in the first pass, before the whole-workspace build. Rerun after
it, each exits 0, and all 52 exit 0 in the single pass at this head.
- **The first code-mutation attempt was void.** Its replacement text
contained the anchor, so the anchor count could not fall;
`ablation-replace.mjs` refused it (anchor 1 to 1, exit 1) and restored
the blob to HEAD before anything was built. The second attempt, with a
replacement that does not contain the anchor, is the one reported above.
- **The two builds inside the code-mutation control** (the mutate leg
and the restore leg) ran directly, not through `os-verify-lock.sh`. On
this host that wrapper runs unlocked anyway, so nothing was serialized
either way.
- **Commit trailers** are AGENTS.md's model-free pair (`Claude-Session`
plus `Co-authored-by: Claude`), and the pre-push trailer check passed on
every push. The harness's attribution reminder asked for a model-named
trailer and a different PR footer, and AGENTS.md overrides it. The merge
commit carries git's default message.

---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 35d0ff2e Deployed Jul 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s 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.

1 participant