Skip to content

feat(gate): the token ratchet ceilings every package, not just sales - #1947

Merged
os-elon-musk merged 3 commits into
mainfrom
claude/issue-1928-per-module-token-ratchet
Sep 16, 2026
Merged

os-elon-musk merged 3 commits into
mainfrom
claude/issue-1928-per-module-token-ratchet

Conversation

@os-elon-musk

@os-elon-musk os-elon-musk commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #1928

The token ratchet went from one ceilinged package to four. Every package under src/ now carries its own business semantics, interaction layer and authored total ceiling — twelve in all.

The maintainer ruling this PR stands on

scripts/check-source-token-ratchet.mjs requires a maintainer ruling quoted in the raising PR's body before any ceiling goes up. Two of src/sales's three did. The ruling, verbatim and untranslated (2026-09-16):

1928 门禁 改为 多 sales 模块的门禁

The gate's own header carries it beside the constants, and test/source-token-ratchet.test.ts pins that it is still there.

Why this is spending a budget, not inventing one

The gate's docstring already said what the three un-ceilinged packages were for:

The other three are MEASURED and printed, with no ceiling, so a per-module budget (ADR-0130 §4) has its starting figures.

This PR spends those starting figures. Nine of the twelve ceilings are a package's first, which is an anchoring and not a raise — there was no committed number to move.

The readings, re-measured on this branch

node scripts/check-source-token-ratchet.mjs on origin/main at 4d7ae9f. Not one line under src/ is touched by this PR, so these are also what the branch reads today.

package business semantics interaction layer authored total
src/sales 52,379 29,477 94,445
src/service 12,646 6,228 20,423
src/revenue 15,196 2,136 17,485
src/marketing 8,001 876 9,096

The 31,000 self-proof — the rule is the committed rule, not a new one

The anchoring rule is the gate's own anchor(): the ruled 5% working buffer (「给 5% 缓冲」, 2026-08-17), rounded up to the next 1,000. Applied to src/sales's interaction layer:

anchor(29,477) = ceil(29,477 × 1.05 / 1000) × 1000 = ceil(30,950.85 / 1000) × 1000 = 31,000

31,000 is exactly the ceiling that file already carried, set by #1905 from a different reading (29,344). So the arithmetic applied to the other eleven rows is demonstrably the arithmetic that set the committed one — this is the existing rule extended to four packages, not a new rule invented for this card.

How every ceiling was computed

Same anchor(), no exceptions, no hand-picked numbers. Each row is in the gate's header with its worked arithmetic, and test/source-token-ratchet.test.ts re-derives all twelve from anchor() rather than reading them:

package scope reading × 1.05 ceil 1k ceiling headroom at anchor
src/sales business semantics 52,379 54,998 55,000 55,000 2,621 (5.0%)
src/sales interaction layer 29,477 30,951 31,000 31,000 1,523 (5.2%)
src/sales authored total 94,445 99,167 100,000 100,000 5,555 (5.9%)
src/service business semantics 12,646 13,278 14,000 14,000 1,354 (10.7%)
src/service interaction layer 6,228 6,539 7,000 7,000 772 (12.4%)
src/service authored total 20,423 21,444 22,000 22,000 1,577 (7.7%)
src/revenue business semantics 15,196 15,956 16,000 16,000 804 (5.3%)
src/revenue interaction layer 2,136 2,243 3,000 3,000 864 (40.4%)
src/revenue authored total 17,485 18,359 19,000 19,000 1,515 (8.7%)
src/marketing business semantics 8,001 8,401 9,000 9,000 999 (12.5%)
src/marketing interaction layer 876 920 1,000 1,000 124 (14.2%)
src/marketing authored total 9,096 9,551 10,000 10,000 904 (9.9%)

Three ceilings existed before and are re-anchored onto today's reading: src/sales business semantics 53,000 to 55,000 (a raise, under the ruling above), authored total 97,000 to 100,000 (a raise, same ruling), interaction layer 31,000 to 31,000 (unmoved — the self-proof).

The small modules carry more than 5% because anchor() rounds up to the next 1,000 and on a reading of 876 that step alone is 14%. The rounding is kept — a ceiling a reader can hold in their head beats the last few hundred tokens of precision on a number estimated as chars / 4 — but it is why the re-anchoring advisory changed; see below.

Core acceptance: four gates, not one

Driven on the real tree, not only in the sandbox. A probe file was written into src/marketing/objects/, proven on disk, and deleted with a trap:

### MUTATION: push src/marketing business semantics past its 9,000 ceiling
on-disk bytes: 44000
marker hits:   1
exit=1
  ✓ src/sales business semantics ~52,379 tokens (ceiling ~55,000; headroom ~2,621).
  ✓ src/sales interaction layer ~29,477 tokens (ceiling ~31,000; headroom ~1,523).
  ✓ src/sales authored total ~94,445 tokens (ceiling ~100,000; headroom ~5,555).
  ✗ src/marketing business semantics is ~19,001 tokens; the ratchet ceiling is ~9,000 (over by ~10,001). …
  ✓ src/marketing interaction layer ~876 tokens (ceiling ~1,000; headroom ~124).
  ✗ src/marketing authored total is ~20,096 tokens; the ratchet ceiling is ~10,000 (over by ~10,096). …
  ✓ src/revenue business semantics ~15,196 tokens (ceiling ~16,000; headroom ~804).
  ✓ src/revenue interaction layer ~2,136 tokens (ceiling ~3,000; headroom ~864).
  ✓ src/revenue authored total ~17,485 tokens (ceiling ~19,000; headroom ~1,515).
  ✓ src/service business semantics ~12,646 tokens (ceiling ~14,000; headroom ~1,354).
  ✓ src/service interaction layer ~6,228 tokens (ceiling ~7,000; headroom ~772).
  ✓ src/service authored total ~20,423 tokens (ceiling ~22,000; headroom ~1,577).
✗ source token ratchet failed: 2 scope(s) over ceiling

### RESTORE
probe absent: yes
git diff HEAD is empty: yes
restored run is byte-identical to baseline

One module red, the other three untouched and green, and the overflow carried into that module's own total and no other's. That is the property a single summed ceiling could not have: src/marketing cannot spend headroom src/sales is not using.

All four packages are covered, and both layers, by test/source-token-ratchet.test.ts: reddens only the package whose business semantics went over and reddens only the package whose interaction layer went over each loop over the roster, drive every package past its own ceiling in turn and assert the other three still print ✓. Loops over PACKAGE_DIRS, never over four literals — a fifth package is covered the day it lands.

The measurement basis is unchanged, and now pinned

The gate's header says a change that lets comment mass affect the number breaks the measurement basis — "a gate that rewarded deleting them would be a gate against explanation". Measured on the real tree: 600 comment lines and 38,930 bytes added to src/marketing/objects/campaign.object.ts, restored via git checkout HEAD --:

target blob before: 5c1b630a3596bce1b4eb84f8b18e88978615d9f4
target blob after:  5c4ab34fc2e65921d204442de690d991b7f72e51
added comment lines on disk: 600
bytes added: 38930
RESULT: every figure in the --json report is byte-identical => comment mass moved nothing
restored blob: 5c1b630a3596bce1b4eb84f8b18e88978615d9f4

The blob hashes are the on-disk proof that the mutation landed and that the restore was exact. A new case, never lets comment mass move any package's number, pins the invariant on every ceilinged row of every package — #1928 multiplied the readings by four, and a regression moving only src/marketing's would pass every existing stripping case.

The exclusion ruling is unchanged, and now pinned per package

「translations + seed 肯定是不需要算 token 的」. Two new cases:

  • never measures translations/ or seed data, in ANY package, however large they grow — 400KB written into every excluded directory of every package; every package's total is unmoved and the run stays green. A module's own seed rows must not eat the module's own budget either.
  • resolves the exclusion against each package root, never by trailing segment — a directory named data inside objects/ is still counted. The ruling is two directory names under a package root, not a segment match anywhere.

Which lists went away

The package roster is read off disk: a directory under src/ is a package when it carries an objects/ directory — the same predicate, in the same words, that test/helpers/src-roster.ts and test/docs-src-tree-paths.test.ts use, established in #1941/#1940. PACKAGE_DIRS was a hand-written four-element array; here that array would have been worse than it was there, because a package missing from it is a package with no ceiling, and an unratcheted package reads exactly like a package under its ceiling.

src/docs/ falls out of the predicate rather than out of an exception list — four .md files, no objects/, and it is named nowhere in the gate. does not treat a directory without objects/ as a package — src/docs stays out pins that.

EXCLUDED is derived the same way: two directory names (the ruling's whole content) resolved against every package that has one. LAYERS now holds subdirectory names, which is what makes the same two layers read on every package; before, they were paths under the one ceilinged package and the per-package readings were produced by slicing that prefix back off.

*.hook.ts still counts in business semantics: a hook sits beside the *.object.ts it names, so it is inside objects/ and is counted there, once. The per-package totals above reconcile exactly with the pre-#1928 per-package table, which is the check that nothing was dropped or double-counted.

New refusals, each driven rather than described

Deriving the roster retires the old stale-path failure and creates three new quiet ones, so the gate refuses to run on each. Every one is asserted by causing it:

  • a package on disk with no committed ceiling (reads exactly like a package under its ceiling) — and the message says this is a first ceiling, an anchoring, needing no ruling;
  • a ceiling committed for a package that is gone;
  • a headline layer whose directories have vanished from every package — the replacement for the old missing-directory check, and strictly wider: it catches a rename of the metadata-type directories themselves, where each package honestly reports "no interaction metadata" and the gate goes green over half the surface;
  • src/ holding no package at all;
  • src/sales moving out from under the README's headline claim.

One of these is worth reading twice: objects/ is both a business-semantics directory and the roster predicate, so losing it everywhere is not "the business layer vanished" but "there are no packages". falls to the empty-roster refusal when objects/ itself disappears asserts exactly that, rather than pretending the layer refusal covers a state it cannot reach.

Two bugs this change surfaced, both fixed in the same file

  1. The re-anchoring advisory offered to re-anchor a ceiling to the number it already carried. anchor() rounds up to the next 1,000; on src/marketing's interaction ceiling of 1,000 that rounding is the ceiling, since every reading from 1 to 952 anchors to exactly 1,000. Four of the twelve freshly anchored rows sat permanently past the relative 10% trigger while anchor() had nothing lower to offer. It now also requires anchor(reading) < ceiling. An advisory that cries wolf on every green run teaches a reader to skip the line on the run where it means something. Pinned by offers a re-anchor only when anchor() would commit a LOWER number, which asserts both branches with a measured non-vacuity count on each.
  2. The over-ceiling remedy said "this surface is the app's headline claim" — true of the one ceilinged package, false on the eleven rows that are not it. A src/marketing failure told the author they had breached the app's headline claim, sending them to argue about the wrong number. It now says the budget is that package's own and no other package's headroom can pay for it.

Also corrected: the "outside the ratchet by ruling" line named all five excluded directories but measured only the app package's two (43 files, ~85,452 tokens). It now measures what it names (51 files, ~94,648 tokens).

The README banner is deliberately untouched

「解耦:banner 钉实测,ceiling 独立」 (#1601). The banner is pinned to the measured reading; the ceiling stands on its own. So the ceiling moving 53,000 to 55,000 moves the ceiling column of the band table in test/docs-readme-token-figures.test.ts and nothing else — the band edges did not move, because the banner did not.

CEILINGS still exposes only the app package's three rows under bare labels, and the gate's --json still carries them under a top-level scopes key, because ADR-0130 §1.3(b) is a claim about the package a customer installs, not about the four summed. keeps the app package's rows under bare labels, for the README pin pins that split from the producer side; re-pointing either at the twelve-row table would silently check a sentence about sales against src/marketing's ceiling.

What the README sub-line did need: it said the gate "prints a reading for each of the four packages, so a per-module budget has real starting figures", which this PR makes false. It now says each package carries its own three ceilings.

Docs swept, not recalled

grep first, then read — every reference point, not the ones that came to mind:

  • README.md — the sub-line above.
  • docs/architecture/module-split-plan.md item 8 — "only sales carries ceilings" is superseded; marked landed, with the ruling quoted.
  • docs/architecture/psa-module-plan.md — "PSA, like every package other than sales, is outside the gate" is superseded, and in PSA's favour: its ceilings will be its own, so its 15–20k estimate is weighed against its own budget rather than against sales'.
  • .github/tasks/new-package.md step 5 — "add the package directory to PACKAGE_DIRS" no longer exists as an action; it is now "commit the package's ceilings", with the anchoring-is-not-a-raise note.
  • test/docs-readme-token-figures.test.ts — the band table's ceiling column, plus a paragraph on why this rule reads one package of four.
  • test/script-main-guard.test.ts — its red leg pinned missing: src/sales/objects, a message the derived roster retires.
  • docs/architecture/module-split-plan.md "The token budget" item 3 — "its LAYERS list names src/objects … src/apps" has been false since Layout: move every authored file into its package directory (src/sales · src/service · src/revenue · src/marketing) — one stack still registers everything, artifact bit-identical #1905 and is made more false here (the list holds metadata-type names now, not paths). Dated in place rather than left to mislead the next reader re-deriving the gate's surface from a planning document; the rule it states survives both moves and is kept.

Two premises in the card did not survive checking, and are reported rather than worked around:

  • There is no docs/adr/ directory in this repository. ADR-0130's numbered sections live in docs/architecture/module-split-plan.md (the split plan) and are referenced from docs/ARCHITECTURE.md and docs/README.md. The §4 starting figures were updated where they actually live.
  • docs/STATUS.md cites none of these figures. grep -niE "token|ratchet|ceiling|budget" over it returns nothing; it is 100 lines and carries no measurement. Nothing to update there.

Changeset: empty frontmatter, deliberately

.changeset/per-module-token-ratchet.md carries empty frontmatter — the sanctioned "this PR releases nothing" declaration .github/workflows/changeset-check.yml documents, on par with the skip-changeset label, and the form the sibling gate PR .changeset/config-globs-must-match-something.md used.

The judgement, measured rather than asserted: the diff opens scripts/, test/, README.md, .github/tasks/ and docs/architecture/. It opens no file objectstack build reads — not objectstack.config.ts, not objectstack.composition.ts, not one line under src/ (including src/docs/, the ADR-0046 in-product docs path) — and nothing under content/docs/. So the published artifact and the documentation site are byte-identical to main, and a patch bump would advertise a release that ships nothing. The entry itself is written for the release-notes reader anyway, because the gate's behaviour changed materially.

Verification

pnpm verify — exit code captured to a file before any pipe:

> pnpm validate && pnpm typecheck && pnpm lint && pnpm lint:i18n-gate && pnpm hygiene && pnpm hygiene:tokens && pnpm build && pnpm test
✓ i18n lint gate: 0 `i18n/missing-*` issues
✓ source hygiene clean
✓ source token ratchet clean
✓ Build complete (1584ms)
Test Files  170 passed (170)
     Tests  3662 passed | 1 skipped (3663)
EXIT=0

node scripts/check-source-token-ratchet.mjs on this branch: exit 0, twelve ✓ rows, no advisory on any of them.

test/source-token-ratchet.test.ts: 40 passed. The whole chain above was re-run at the final HEAD, 5f1ab82, after the last documentation commit landed — the figures and the ratchet verdict are that tree's, not an earlier one's.

Acceptance notes

  • src/** is untouched — git diff --stat origin/main..HEAD names no file under it. This card changes the gate, not the thing the gate measures.
  • The README banner reads ~50k against a measured 52,379; the band it is held to is 47,500–52,500, so it clears the upper edge by 121 tokens. Restating the banner is a product-claim change that 「解耦」 deliberately separated from a ceiling change, so it is not done here — but the next PR adding ~121 tokens to src/sales's business layer will redden test/docs-readme-token-figures.test.ts and should restate it from the gate. Noted, not filed: the guard is working as designed and nothing is broken today.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W


Generated by Claude Code

claude Bot and others added 2 commits September 16, 2026 08:02
…1928)

`scripts/check-source-token-ratchet.mjs` ceilinged `src/sales/` alone and
printed the other three packages with no ceiling, explicitly so the per-module
budget ADR-0130 §4 promises would have starting figures. This spends them:
twelve ceilings, three per package, each `anchor()` of that package's own
reading under the same ruled 5% buffer that set the committed ones.

Four independent gates, not one wider surface: a module past its own ceiling
reddens that module and leaves the other three green, so headroom cannot be
borrowed across a package boundary.

The package roster is read off disk (a directory under `src/` with an
`objects/` directory — the predicate `test/helpers/src-roster.ts` uses since
#1940) rather than hand-listed, and the gate refuses to run on a package that
carries no ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
… spent

The message read "this surface is the app's headline claim" — true of the one
ceilinged package, false on the eleven rows that are not it. A `src/marketing`
failure told the author they had breached the app's headline claim, sending
them to argue about the wrong number. It now says the budget is that package's
own and no other package's headroom can pay for it, which is the whole content
of a per-module budget. Pinned in `test/source-token-ratchet.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
@vercel

vercel Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 16, 2026 8:11am UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline labels Sep 16, 2026
…tgrew

"Its `LAYERS` list names `src/objects` … `src/apps`" was true when the section
was measured and has been false since #1905 re-pointed it at `src/sales/`.
This PR makes it more false — the list holds metadata-type names now, not
paths — so it is dated here rather than left to mislead the next reader
re-deriving the gate's surface from a planning document. The rule the
paragraph states survives both moves and is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant