Repository navigation
Add AI conversation memory and cost tracking protocols - #88
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
|
This PR is very large. Consider breaking it into smaller PRs for easier review. |
There was a problem hiding this comment.
Pull request overview
Adds new AI protocol schemas for (1) multi-turn conversation memory with token budgeting and (2) detailed cost tracking with budget/alert/reporting structures, and wires them into the AI module exports with accompanying Vitest coverage.
Changes:
- Introduces
Conversation*Zod schemas/types for message/session state, token budgeting, pruning events, and analytics. - Introduces
Cost*Zod schemas/types for cost entries, budgets/status, alerts, analytics, and query filters. - Adds Vitest test suites for both protocols and exports them from
packages/spec/src/ai/index.ts(plus new npm lockfiles).
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/ai/index.ts | Exposes the new conversation and cost protocols from the AI namespace. |
| packages/spec/src/ai/conversation.zod.ts | Defines conversation message/session/token budget schemas and derived types. |
| packages/spec/src/ai/conversation.test.ts | Adds Vitest coverage validating conversation protocol parsing and defaults. |
| packages/spec/src/ai/cost.zod.ts | Defines cost tracking/budget/alert/report schemas and derived types. |
| packages/spec/src/ai/cost.test.ts | Adds Vitest coverage validating cost protocol parsing and defaults. |
| packages/spec/package-lock.json | Adds an npm lockfile for @objectstack/spec. |
| package-lock.json | Adds an npm lockfile at monorepo root. |
Files not reviewed (1)
- packages/spec/package-lock.json: Language not supported
| { | ||
| "name": "@objectstack/spec", | ||
| "version": "0.3.0", | ||
| "lockfileVersion": 3, | ||
| "requires": true, | ||
| "packages": { | ||
| "": { | ||
| "name": "@objectstack/spec", | ||
| "version": "0.3.0", | ||
| "license": "Apache-2.0", | ||
| "dependencies": { | ||
| "zod": "^3.22.4" |
There was a problem hiding this comment.
Since the monorepo uses pnpm (root package.json packageManager + pnpm-lock.yaml), committing an additional packages/spec/package-lock.json adds a second lockfile for the same package set and can lead to inconsistent installs. Consider removing this lockfile and relying on pnpm’s lockfile only.
| */ | ||
| export const MessageContentSchema = z.object({ | ||
| type: MessageContentTypeSchema.default('text'), | ||
| text: z.string().optional().describe('Text content'), | ||
| imageUrl: z.string().url().optional().describe('Image URL for vision models'), | ||
| fileUrl: z.string().url().optional().describe('File attachment URL'), | ||
| mimeType: z.string().optional().describe('MIME type for files'), | ||
| metadata: z.record(z.any()).optional().describe('Additional metadata'), | ||
| }); | ||
|
|
There was a problem hiding this comment.
MessageContentSchema is too permissive: it allows invalid combinations like { type: "image" } without imageUrl, or imageUrl/fileUrl present while type defaults to text. Consider modeling content as a discriminated union on type (e.g., text requires text, image requires imageUrl, file requires fileUrl, etc.) so invalid message payloads are rejected at validation time.
| */ | |
| export const MessageContentSchema = z.object({ | |
| type: MessageContentTypeSchema.default('text'), | |
| text: z.string().optional().describe('Text content'), | |
| imageUrl: z.string().url().optional().describe('Image URL for vision models'), | |
| fileUrl: z.string().url().optional().describe('File attachment URL'), | |
| mimeType: z.string().optional().describe('MIME type for files'), | |
| metadata: z.record(z.any()).optional().describe('Additional metadata'), | |
| }); | |
| * | |
| * Discriminated union on `type` to ensure valid combinations: | |
| * - `text` → requires `text` | |
| * - `image` → requires `imageUrl` | |
| * - `file` → requires `fileUrl` | |
| * - `code` → requires `text` (code content) | |
| * - `structured`→ primarily uses `metadata` / optional `text` | |
| */ | |
| const BaseMessageContentMetadataSchema = z.object({ | |
| metadata: z | |
| .record(z.any()) | |
| .optional() | |
| .describe('Additional metadata'), | |
| }); | |
| export const MessageContentSchema = z.discriminatedUnion('type', [ | |
| // Plain text content | |
| BaseMessageContentMetadataSchema.extend({ | |
| type: z.literal('text'), | |
| text: z.string().describe('Text content'), | |
| }), | |
| // Image content (for vision models), with optional caption/alt text | |
| BaseMessageContentMetadataSchema.extend({ | |
| type: z.literal('image'), | |
| imageUrl: z.string().url().describe('Image URL for vision models'), | |
| text: z.string().optional().describe('Alt text or caption for the image'), | |
| }), | |
| // File attachment content | |
| BaseMessageContentMetadataSchema.extend({ | |
| type: z.literal('file'), | |
| fileUrl: z.string().url().describe('File attachment URL'), | |
| mimeType: z.string().optional().describe('MIME type for files'), | |
| text: z.string().optional().describe('Optional description of the file'), | |
| }), | |
| // Code content (source code snippet) | |
| BaseMessageContentMetadataSchema.extend({ | |
| type: z.literal('code'), | |
| text: z.string().describe('Code content'), | |
| mimeType: z | |
| .string() | |
| .optional() | |
| .describe('MIME type or language identifier for the code'), | |
| }), | |
| // Structured content (JSON-like payloads, tables, etc.) | |
| BaseMessageContentMetadataSchema.extend({ | |
| type: z.literal('structured'), | |
| text: z.string().optional().describe('Optional human-readable summary of the structured content'), | |
| }), | |
| ]); |
| strategy: TokenBudgetStrategySchema.default('sliding_window'), | ||
|
|
||
| /** Strategy-Specific Options */ | ||
| slidingWindowSize: z.number().int().positive().optional().describe('Number of recent messages to keep'), |
There was a problem hiding this comment.
TokenBudgetConfigSchema defaults strategy to sliding_window, but slidingWindowSize is optional, which makes the default configuration ambiguous (it’s not clear what window size applies). Consider either (a) providing a sensible default for slidingWindowSize, or (b) making the config a discriminated union so slidingWindowSize is required when strategy === "sliding_window" (and similarly require minImportanceScore/semanticThreshold for their strategies).
| slidingWindowSize: z.number().int().positive().optional().describe('Number of recent messages to keep'), | |
| slidingWindowSize: z.number().int().positive().default(50).describe('Number of recent messages to keep'), |
| */ | ||
| export const ConversationContextSchema = z.object({ | ||
| /** Identity */ | ||
| sessionId: z.string().describe('Conversation session ID'), |
There was a problem hiding this comment.
ConversationSessionSchema contains both context.sessionId and a top-level id, but there’s no guarantee they match. This can lead to inconsistent session identifiers across the protocol. Consider removing sessionId from ConversationContextSchema (derive it from ConversationSession.id), or add validation to enforce equality.
| sessionId: z.string().describe('Conversation session ID'), |
| /** Period */ | ||
| period: BillingPeriodSchema, | ||
| customPeriodDays: z.number().int().positive().optional().describe('Custom period in days'), | ||
|
|
There was a problem hiding this comment.
BudgetLimitSchema allows period: "custom" without customPeriodDays, even though the schema describes customPeriodDays as the custom period definition. Consider enforcing that customPeriodDays is required when period === "custom" (and ideally disallow it for non-custom periods) so invalid budget configurations don’t validate.
…igration Resolves the modify/delete conflict left by #17073, which added an `operatorFacingErrorText(err)` hunk to `packages/metadata/src/migrations/migrate-sys-notification-to-event.ts` — the module this branch deletes. The deletion is kept: the director-seat ruling of 2026-09-08 (decision batch #88) removes the runner, its barrel export and its tests in one PR, so #17073's hunk goes with the file it edits. Second, non-textual half of the same conflict: #17073 also added `packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts`, which imports the deleted module and carries one describe block for it. That import and that block are removed — deletion only, no line authored; the three describe blocks covering the surviving migrations are kept untouched, as is every other file main brings. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…d the retired migration `migrateSysNotificationToEvent` was removed from `@objectstack/metadata/migrations` by the retirement ruling in decision batch #88. Two ADR lines still read as live statements about a runner that no longer exists. ADR-0030 `:105` — a prescription in the objectui cut-over list ("Run `migrateSysNotificationToEvent` during the cut-over") — is struck in place and replaced with the withdrawal, the consequence (pre-ADR-0030 `sys_notification` rows are not carried by the platform on this line) and a pointer to the handoff doc's tombstone, which holds the reasoning, the unmeasured-deployment caveat and the reversal path. The cut-over sequence drops its middle step. ADR-0052 `:327` said `sys_notification` "is mid-migration to an event model (`metadata/.../migrate-sys-notification-to-event.ts`, ADR-0030)". Both halves are false: the migration is retired and the file is deleted. The line now records why P0b was deferred and states that the collision reason is gone, without deciding whether the move proceeds — that is a call for that record's owner. ADR-0030's status line gains an `Amended` entry naming the retirement, per Prime Directive #13: a reversal of a recorded decision is itself recorded on the record. ADR-0030 `:80` is deliberately NOT touched. The `P0 — Seams` row states what #1434 shipped, and it did ship; editing it would rewrite history to make a grep pass. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…d the retired migration (objectstack-ai#19381) Fixes objectstack-ai#17193 Clause-②: no ## The defect `docs/adr/0030-notification-platform-convergence.md` carried a live operator prescription for `migrateSysNotificationToEvent` — a runner the objectstack-ai#16194 retirement (decision batch objectstack-ai#88) removed from `@objectstack/metadata/migrations`. An operator following the cut-over list writes an import that does not resolve: a copy-the-example-and-it-fails defect, not a stylistic one. ## ⭐ The discrimination this card is about — two occurrences, ONE defect | occurrence | what it is | disposition | |---|---|---| | `0030` § *Remaining work* → objectui cut-over — "Run `migrateSysNotificationToEvent` during the cut-over…" | a **prescription addressed to someone about to act** | **corrected** | | `0030` § *Shipped (merged to `main`)* → `P0 — Seams` row — "…idempotent `migrateSysNotificationToEvent`. \| objectstack-ai#1434" | a **record of what a past release shipped**, true when written | ⛔ **left byte-for-byte as it stands** | **My reading of the file agrees with the card's split, and here is the structural evidence for it rather than a restatement.** The untouched occurrence sits inside the table under the heading `### Shipped (merged to `main`)` — one row per phase, each carrying the PR number that shipped it (`objectstack-ai#1434`). The corrected occurrence sits under `### Remaining work (handed off to a follow-up agent)`, in an imperative bullet list of steps a follow-up agent is told to perform. Retro-editing the shipped row would be rewriting history to make a grep pass, and it is the accrete-a-row-per-release pattern the release guardrail exists to stop. `git diff` on this branch contains **zero** hits for `P0 — Seams` — the row is not in the diff at all. ##⚠️ The widening request (`5611847419`) — **TAKEN**, and declared The `CONTRACT_REVIEW_TIER` verdict on PR objectstack-ai#17194 (finding F2) named a second line of the same class in a different ADR. The dispatching seat did **not** rule it in or out of scope and asked for the decision to be made out loud. **It is taken**, and the same test was applied to it first: - `docs/adr/0052-audit-is-not-the-activity-feed.md` § *6. Rollout* → *P0b* read "`sys_notification` **is deferred** — it **is mid-migration** to an event model (`metadata/.../migrate-sys-notification-to-event.ts`, ADR-0030), so moving it now would collide with that in-flight work." - Present tense, a claim about **what the platform does today**, and it names a path that is **deleted from disk**. That puts it on the `:105` side of the test, not the shipped-history side. Both halves of the sentence are false as of the retirement. **Why take it rather than file it.** It is the same defect, from the same retirement, in the same governed tree, and it needs the same sentence written. Leaving it means a second card, a second governed-surface PR and a second hand-merge for one sentence. The PM comment that recorded it declined to file it as a second card for exactly that reason. **What the correction deliberately does NOT do.** It records that the stated collision reason is gone — it does **not** decide whether the `sys_notification` ownership move now proceeds. That disposition is a decision for ADR-0052's own record owner, and the line says so in as many words. ADR-0052's **status line is not amended**: no decision of ADR-0052 moved, only one sentence of rationale was false. ## The one judgement call — ADR-0030's amended status line The card's "Suggested shape" asked for a decision on whether ADR-0030 wants an amended status line naming the retirement. **It does, and the PR writes one** (one line, appended to the existing `**Status**` line). Prime Directive objectstack-ai#13: reversing a recorded decision is itself a decision, and it needs a new ADR **or an amended status line on the old one**. The retirement already happened, elsewhere; because `docs/adr/**` is governed, PR objectstack-ai#17194 correctly could not carry the amendment, so this PR is where it is owed. Without it, the Status line still reads "**P0–P3b2 shipped**" with no trace that a shipped P0 item was withdrawn, and a reader who greps the status learns nothing. The wording follows the register's own precedents for this exact shape — ADR-0005 (`Amended (2026-08-09, objectstack-ai#6825 — the Phase-1 overlay-index migration is deleted…)`), ADR-0127 and ADR-0045 — and closes with an explicit "nothing else moves" so it cannot be read as a wider reversal.⚠️ **This is the line to strike first** if the seat or the maintainer wants a smaller diff: the `:105` correction stands on its own without it. ## Premise re-verified on this branch's base (`32708262d`) ``` $ git grep -nE 'migrateSysNotificationToEvent' -- . ':!*CHANGELOG.md' ':!content/docs/releases/' .changeset/retire-adr-0030-notification-event-migration.md:21:- `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. docs/adr/0030-notification-platform-convergence.md:80 (the shipped-history row — untouched) docs/adr/0030-notification-platform-convergence.md:105 (the prescription — this PR) docs/adr/0052-audit-is-not-the-activity-feed.md:327 (via its file path — this PR) docs/handoff/adr-0030-notification-convergence.md:126 (the tombstone, already correct) packages/metadata/src/migrations/index.ts:50 (barrel TOMBSTONE) packages/spec/src/system/migration.zod.ts:175 (retired id docblock) + three retirement pin tests ``` `packages/metadata/src/migrations/` holds no `migrate-sys-notification-to-event.ts`. Every remaining occurrence in shipped code is a tombstone or a pin asserting the absence. ⇒ the premise **holds**: the prescription names a call that no longer exists. ## Local gates — 19 derived, 19 run, 0 NOT MEASURED Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` against the actual changed files, every exit code captured **before** any pipe, reconciled with `--ran` in the `command :: exit code` form the tool asks for: ``` ✓ dispatch-gates --ran: 19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of them is 3). ``` All 19 exited **0**, the ADR-specific ones among them: `check-adr-links.mjs` · `check-adr-symbol-anchors.mjs` · `check:adr-anchors` · `check:doc-authoring` · `check:pm-governed-merges` · `check:pm-prior-rulings` · `check:nul-bytes` · `@objectstack/lint check:doc-formula-expressions`. `check:doc-formula-expressions` first exited **3 — PREREQUISITE NOT MET** (`@objectstack/formula` and `@objectstack/lint` unbuilt). That is not a finding and was not recorded as one: the two packages were built and the gate re-run, where it exited 0. **Build / test scope.** The diff touches no package, so the dependency-closure build is empty and no package's `test`/`typecheck` is affected. The build above was a gate prerequisite only. **Repo-wide eslint — a measured narrowing, not a skipped run.** (1) Population, read from eslint's own config: every `files:` block in `eslint.config.mjs` matches only `{ts,tsx,mts,cts,js,jsx,mjs,cjs}`; no block matches Markdown. (2) Count, from `--format json` over both changed files: 2 files, both `File ignored because no matching configuration was supplied`, 0 errors. (3) Non-influence: no block sets `parserOptions.project` or `projectService`, so type-aware linting is not enabled and this diff cannot move the verdict on any untouched file. ## Changeset — `skip-changeset` is warranted, and is NOT applied by this PR The repo has a definite answer and it is the **label**, not an empty changeset: `AGENTS.md` *Post-Task Checklist* §3 scopes `skip-changeset` to "a diff that publishes nothing from any released package", and the empty-changeset route was ruled shut for new files (objectstack-ai#5471) — the `Check Changeset` job rejects an empty changeset a PR newly introduces. A docs-only `docs/adr/` diff publishes nothing from any package, so the label is the correct instrument. ⛔ **The dispatching seat's write budget names no label, so this dev did not apply it.** The `Check Changeset` gate will be red until the seat applies `skip-changeset`; that red is the gate working, not a finding. ## Files changed - `docs/adr/0030-notification-platform-convergence.md` — status-line amendment; the cut-over prescription struck and replaced. - `docs/adr/0052-audit-is-not-the-activity-feed.md` — the P0b rollout rationale corrected. Total: 2 files, +16 / −7. ## ⛔ Landing `docs/adr/**` is a governed surface (Prime Directive objectstack-ai#14, Tier H). This PR stays **draft** and takes no queue: it lands by the maintainer's hand or on an authorised approval, and no agent seat may flip it ready, enqueue it, arm auto-merge on it, or approve it. --- ## 维护者速读(草稿) > 席位意见一节留空,待席位填写后贴出终稿评论。 ### 改了什么 两个 ADR 文件里的两句话,共 2 个文件、+16 / −7 行,不含任何代码改动。 1. **ADR-0030**(通知平台收敛):「前端切换时运行 `migrateSysNotificationToEvent` 把历史铃铛数据迁过来」这条**操作指令**被划掉,改成记录「该迁移已撤回」,并指向 handoff 文档里已有的墓碑说明;切换步骤从三步变两步。同时在文件顶部的 `Status` 行补了一条 `Amended` 记录,写明这次撤回。 2. **ADR-0052**(审计不是动态流):P0b 那段说 `sys_notification`「正在迁往事件模型(某某文件,ADR-0030)」——这句话的两半现在都是假的(迁移已退役、文件已从磁盘删除)。改成记录「当初因此推迟」+「该理由已不存在」,但**不替记录所有者决定这次搬迁现在要不要做**。 3. ⛔ **ADR-0030 里另一处同名调用(P0 已交付表格第 80 行)一个字没动** —— 它记录的是 objectstack-ai#1434 当年确实交付了什么,是历史,不是现状。 ### 为什么改 被点名的那个函数在 objectstack-ai#16194(决策批次 objectstack-ai#88)里已经从 `@objectstack/metadata/migrations` 删掉了。ADR-0030 那条指令是写给「马上要动手的人」看的:照抄它写出来的 import 解析不了。这属于「照着例子做就失败」,不是措辞问题。 ADR-0052 那句的危害不同但同源:它给一次**推迟**提供理由,而那个理由已经蒸发;后来的人可能继续拿一条死掉的理由往后推。 顶部 `Status` 行那条 `Amended` 是协议第 13 条要求的:推翻一个已记录的决定本身就是决定,必须记在记录上。撤回发生在别处(objectstack-ai#16194),而那个 PR 因为受管面规则不能碰 ADR,所以这笔账落在本 PR。 ### 风险与代价(含回滚) - **风险很低**:纯文档散文改动,无代码、无发布面、无生成物。19 个派生门禁全部 exit 0(含 ADR 链接、ADR 锚点、受管面合并审计)。 - **唯一需要您拍板的**:顶部那条 `Amended` 状态行算不算「越界替记录所有者做决定」。本 PR 的判断是「不算 —— 它只是把别处已经做出的裁定记到该记的地方」,但如果您觉得 diff 该更小,**先划掉这一行**,下面 `:105` 的修正独立成立。 - **回滚**:`git revert` 一个 commit 即可,不牵动任何运行时。 ### 席位意见 (留空) ### 你要做的 读完 diff 的 16 行后,**人工合并本 PR**(或给出授权批准)—— ⛔ 它不进合并队列,agent 席位不会翻 ready、不会入队。另外请给它挂上 `skip-changeset` 标签,否则 `Check Changeset` 会一直红(本 PR 不发布任何包,派发席的写预算里没有标签,所以 dev 没有自行挂)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ Co-authored-by: Claude <noreply@anthropic.com>
…d decision in words instead of a tracker number (stage 27) (objectstack-ai#21997) Part of objectstack-ai#20749 Clause-②: no Stage 27 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the second and last name-ordered `system/` group: the 16 id-bearing test files under `packages/spec/src/system/` from `metadata-form-zod-reconciliation.test.ts` to `worker.test.ts`. Those files carried 63 messages and 70 tracker ids. All 70 now either state what their record decided, in words (form D), or are dropped where the string already says it. No needle sits in this group. Text only: no assertion, identifier, test count or code comment changes, and no file is renamed. This finishes `system/` for this class. ## Census at the base (`aa09db58c9`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages 10 to 26 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. The worktree was cut from `origin/main` at `aa09db58c9`, the claim's base and stage 26's landing. Both instruments read **191 messages / 200 ids in 53 files**, the seat's reading and stage 26's head reading. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `system/` (this PR: all 16) | 16 | 63 / 70 | 45 / 49 | 18 / 21 | | `ui/` | 5 | 7 / 7 | 0 | 7 / 7 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **53** | **191 / 200** | **162 / 168** | **29 / 32** | The group reads **63 messages / 70 ids in 16 files**, the seat's figures file for file: | file (under `system/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `metadata-form-zod-reconciliation.test.ts` | 21 / 24 | 4 / 4 | 17 / 20 | | `metadata-persistence.test.ts` | 1 / 1 | 1 / 1 | 0 | | `metrics.test.ts` | 4 / 5 | 4 / 5 | 0 | | `notification-event-migration-retirement.test.ts` | 3 / 3 | 2 / 2 | 1 / 1 | | `notification.test.ts` | 1 / 1 | 1 / 1 | 0 | | `object-storage.test.ts` | 1 / 1 | 1 / 1 | 0 | | `operation-message.test.ts` | 3 / 3 | 3 / 3 | 0 | | `registry-config.test.ts` | 1 / 1 | 1 / 1 | 0 | | `settings-manifest.test.ts` | 5 / 5 | 5 / 5 | 0 | | `stack-server.test.ts` | 2 / 2 | 2 / 2 | 0 | | `tenant-provisioning-family-retired.test.ts` | 1 / 2 | 1 / 2 | 0 | | `tenant.test.ts` | 3 / 5 | 3 / 5 | 0 | | `tracing.test.ts` | 3 / 3 | 3 / 3 | 0 | | `translation.test.ts` | 12 / 12 | 12 / 12 | 0 | | `validation-message.test.ts` | 1 / 1 | 1 / 1 | 0 | | `worker.test.ts` | 1 / 1 | 1 / 1 | 0 | | **16 files** | **63 / 70** | **45 / 49** | **18 / 21** | Five more test files sit in the same name range and carry no id (`migration`, `search-engine`, `security-context`, `supplier-security`, `translation-typegen`). The 18 "other" strings are declared to the text-only tool: the 17 ledger reasons in `metadata-form-zod-reconciliation.test.ts` (`LEDGER` rows `:256`, `:263`, `:270`, `:283`, `:426`, `:433`, `:440`, `:447`, `:454`, `:470`, `:477`, `:484`, `:491`, `:507`, `:514`, `:521`, and the `TOP_LEVEL_DEFERRED.view` reason at `:880`), and one expect failure message at `notification-event-migration-retirement.test.ts:165`. - **Controls.** Lit: `ai/build-progress.test.ts` (2 / 2) and three files directly in `src/` (`assembled-package-body` 4 / 4, `compose-key-dispositions-export.pin` 5 / 5, `compose-stacks-action-collision-shape` 3 / 3), outside the group, read the same at the base and at the head. Dark: the 16 files read 0 at the head while 141 of their lines still carry `#` plus digits, every one of them a comment line. Planted in a scratch tree: an id put into the rewritten "page component copy, keyed by component id" title reads 1 / 1 (`title:describe`), the base text put back into the `:440` ledger reason reads 1 / 1 (`other`), and an id put into a `metrics.test.ts` comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same as the gate pattern in all 16 files at the base, and 0 in all 16 at the head. - **At the head:** 128 messages / 130 ids in 37 files. The 16 files read 0 / 0, `system/` is absent, and no other file moved. ## How the area was chosen `system/` is taken in name-ordered file groups near the ~100-id bound, the rule stages 20 to 26 used. Stage 26 named this group at 70 ids, and this census reads 70, so no re-cut was needed. **Named for the next stage** (cut from the head census, 128 / 130): - **The files directly in `src/`:** 30 files, 118 messages / 120 ids (117 / 119 titles, 1 / 1 other, at `stack-cross-reference-envelope.test.ts`). The largest file is `stack-inline-action-crossref.test.ts` at 13, and no file needs splitting. It fits one PR and one text-only proof at 120, 20% over the ~100 bound. If the seat holds to "within about 10%", the name-order cut is two groups: `assembled-package-body.test.ts` through `inline-grid-column-carriers.test.ts` (18 files, 61 ids) and `root-entry-migrations-split.pin.test.ts` through `stack.test.ts` (12 files, 59 ids). - The needles: one stage, with an at-tier review. The four colour literals stay, as stage 21 decided. ## What each id became - **22 literals (24 ids)** now state a decision in words. - **1 literal (1 id)** gets its subject back in words. - **40 literals (45 ids)** drop a number the string already explains. Every cited record was fetched with all its comments through REST. 37 records are cited, plus one decision-batch number (below): 35 answer 200, and 2 answer 404. The two that answer 404 were read from what landed, through the commits endpoint (this checkout is shallow): - **objectstack-ai#10926**, from `d173125fb8` (objectstack-ai#11438): the component-translation `submitLabel` copy key is retired, option A per the maintainer ruling of 2026-08-22; - **objectstack-ai#12493**, from `aa5994e17a` (objectstack-ai#12626, found through its `@objectstack/spec` CHANGELOG entry): the catalog gains two situation keys, `record_write_denied` and `approval_recall_not_submitter`, each its own sentence, "deliberately not `record_access_denied` restated". **The ledger reasons in `metadata-form-zod-reconciliation.test.ts`** (17 "other" strings). Before any was rewritten, every reader of the ledger and of the file was found: - **The file's own assertions** read the `why` text: `e.why.startsWith(reason)` (the class phrase that leads each ruled root row), `toContain('5861442317')` (the ruling record id, in every ruled root row), ``toContain(`the \`${editor.type}\` type`)`` and `toContain(editor.surface)` (the editor names), the unspellable union arms in backticks, and `why.length > 20`. Every rewrite keeps all of them, so no assertion moves: only the card numbers go, and the record id `5861442317` stays. - **Outside the file:** `packages/spec/scripts/lib/zod-graph.ts`, `zod-graph.test.ts`, `scripts/pm/check-widening-tells.mjs`, `metadata-form-declared-rows.pin.test.ts`, three `*.form.ts` comments and the CHANGELOGs name the file. None reads a `why` string: they cite the file in prose or comments. No gate, generated artifact, docs table or self-test reads the text. So no non-test file moves. - **objectstack-ai#19332's ruling is record `5861442317`** (batch objectstack-ai#229 item 3, A on all five groups). Each reason already states its class and why, so "(ruling record 5861442317, objectstack-ai#19332)" becomes "(ruling record 5861442317)" in 15 rows. - **`:433`** cited objectstack-ai#19330 too: "(ruling record 5861442317; per-arm view forms, ruled: one registered form per view kind)". objectstack-ai#19330's ruling A (`5754204415`): "`view` is reconciled PER ARM. Each view kind … gets its own registered metadata form". - **`:454`** cited "objectstack-ai#18164 batch objectstack-ai#209 item 1 A" after the record id `5755653853`. objectstack-ai#18164's ruling `5755653853` is decision batch objectstack-ai#209 item 1, letter A, so `objectstack-ai#209` is a batch number, not a citation (objectstack-ai#209 itself is an unrelated 2026 docs PR). The reason already says what was decided ("the picker placed there"; "its offer was decided as that picker in the object designer's select-field editor"), so it now reads "(ruling record 5861442317; the picker placed there by ruling record 5755653853)". - **`:880`** (`TOP_LEVEL_DEFERRED.view`) already says "It is reconciled per arm, each arm against its own registered form", so "(the objectstack-ai#19330 ruling, letter A)" becomes ", as ruled". **"ruled" appears in two rewritten strings**, both on objectstack-ai#19330's ruling A (`5754204415`): `:433` and `:880` above. **The same-id titles stage 26 listed:** - the five `(objectstack-ai#15679)` titles (`metrics:500`, `object-storage:864`, `registry-config:215`, `tracing:524`, `worker:561`) now say "carry their unit in the key name" / "carries its unit in the key name". objectstack-ai#15679 is objectstack-ai#14478's ruling B (`5548763981`) for `system/`: the fifteen duration keys carry their unit in the key name. That is stage 25's objectstack-ai#15677 reading and stage 26's form; - `translation.test.ts`'s six: `objectstack-ai#16772` and `objectstack-ai#6080` state their decisions (below); `objectstack-ai#10926`, `objectstack-ai#21257`, `objectstack-ai#11287` and `objectstack-ai#4667` drop, because each title already says it (retired, retired, the keys the resolver acts on, retired). **Stated in words** (22 literals): | record | literal (under `system/`) | now reads | the decision | |:--|:--|:--|:--| | objectstack-ai#19332, objectstack-ai#19330 | `metadata-form-zod-reconciliation.test.ts:433` | "(ruling record 5861442317; per-arm view forms, ruled: one registered form per view kind)" | Above. | | objectstack-ai#3786 | `metadata-form-zod-reconciliation.test.ts:886` | "metadata form ↔ Zod reconciliation — a hand-written form held to its schema by a gate" | The template for the hand-copied-list pattern: derive from the one source, and where it cannot be derived, a coverage assertion is the gate. | | objectstack-ai#15679 (objectstack-ai#14478 ruling B) | `metrics.test.ts:500`, `object-storage.test.ts:864`, `registry-config.test.ts:215`, `tracing.test.ts:524`, `worker.test.ts:561` | "… carry their unit in the key name" / "… carries its unit in the key name" | Above. | | objectstack-ai#15939, objectstack-ai#14478 | `metrics.test.ts:584` | "metrics JSDoc-only durations carry their unit in the describe and the key name" | objectstack-ai#15939's ruling (`5564447683`, option 2): a key whose JSDoc names a unit its describe does not is a divergence, refused; the unit moves into the describe, where objectstack-ai#14478's rule puts it in the key name. Ruling A (`5635659224`) remediates per file. | | objectstack-ai#17785 | `tracing.test.ts:565` | "the OTel exporter and performance durations carry their unit in the describe and the key name" | The tracing file's remediation under objectstack-ai#15939's ruling A: the four keys renamed with their unit, and the unit published in the describe. | | objectstack-ai#16194 | `notification-event-migration-retirement.test.ts:165` (expect message) | "… — its runner was retired, with no operator door and no boot-time invoker" | The ruling (`5582372148`, batch objectstack-ai#88): retire; no operator door and no platform invoker. | | objectstack-ai#7414 | `operation-message.test.ts:101` | "operation message catalog — permission_denied, the 403 refusal as localized user copy" | The 403 refusal renders through the operation message catalog in the caller's locale, naming no object, operation or position. | | objectstack-ai#7451 | `operation-message.test.ts:193` | "operation message catalog — the row-level user copy, a sentence per situation" | The row-level denials get their own keys (`record_access_denied`, `record_change_not_allowed`), not the grant denial restated. | | objectstack-ai#12493 (404) | `operation-message.test.ts:306` | "… sharing write denial and approvals recall, two keys of their own" | What landed in `aa5994e17a`, above. | | objectstack-ai#5933 | `settings-manifest.test.ts:230` | "Specifier.valueDomain — a declared standard domain is the boundary, `options` a UI list" | When `valueDomain` is declared, the standard domain is the enforcement boundary and `options` degrades to a UI convenience list. | | objectstack-ai#5131 | `settings-manifest.test.ts:246` | "is optional — an undeclared specifier keeps exhaustive-options semantics, enforced at save" | A `select` value outside its `options` is refused at save. objectstack-ai#5933 keeps that for an undeclared specifier. | | objectstack-ai#7327 | `settings-manifest.test.ts:306` | "`visible` — the settings visibility grammar, narrowed to what the save-time evaluator runs" | Direction (b): narrow the declared type to the grammar the save-time evaluator implements, not CEL. | | objectstack-ai#15811 | `settings-manifest.test.ts:338` | "REFUSES an `ast`-only envelope — an evaluated slot requires a non-blank `source`" | Ruling A (`5644350409`, batch objectstack-ai#122 item 2): every engine-evaluated expression slot requires a non-blank `source`. | | objectstack-ai#4938 | `stack-server.test.ts:61` | "server: carries only keys with a consumer — the retired HttpServerConfig keys stay out" | The maintainer ruling A (`5173147201`): the unreachable `HttpServerConfigSchema` and its seven dead keys are retired. | | objectstack-ai#16772 | `translation.test.ts:840` | "dashboard global-filter copy, addressable from a bundle by filter name" | Finding B: `dashboards.NAME.globalFilters.KEY` becomes a bundle group, keyed by the filter name. | | objectstack-ai#6080 | `translation.test.ts:901` | "page component copy, keyed by component id" | Page component copy gets a bundle address by component id. | | objectstack-ai#7646 | `translation.test.ts:1082` | "screen-flow copy — a `flows` bundle group, runner chrome kept out" | The recorded ruling (`5253154523`): a `flows` surface for flow, screen and field copy; runner chrome stays in the console's own catalog. | | objectstack-ai#3957 | `validation-message.test.ts:90` | "renderValidationMessage — English output is unchanged, the field label in place of the API name" | Messages name the field by its label, through the message catalog. The describe pins the English output as before, label for API name. | **Subject back in words** (1 literal): `translation.test.ts:713` "should reject the retired shape …" now names it, "the retired object-first `o.` shape". objectstack-ai#3778 retired that shape for the `translation` type. **Dropped where already stated** (40 literals, 45 ids). A number goes only where the string already says its decision. Examples: the 15 ledger reasons above; `:880`'s "(the objectstack-ai#19330 ruling, letter A)"; the tails `(objectstack-ai#5280)`, `(objectstack-ai#14327)`, `(objectstack-ai#19332)`, `(objectstack-ai#18124)` x2, `(objectstack-ai#5933)`, `(objectstack-ai#4001)` x3, `(objectstack-ai#14478, objectstack-ai#14519)`, `(objectstack-ai#14519)`, `(objectstack-ai#15939, objectstack-ai#14478)` on "→ schemaCacheTtlSeconds", `(objectstack-ai#10926)`, `(objectstack-ai#21257)`, `(objectstack-ai#11287)`, `(objectstack-ai#15178)`, `(objectstack-ai#19620)`, `(objectstack-ai#4667)`; and the prefixes `objectstack-ai#18118` x2 (before "the retired CEL arm"), `[objectstack-ai#16194]` x2, `[objectstack-ai#4616]` and `[objectstack-ai#4739 / objectstack-ai#16325]`. The one 404 number among them (objectstack-ai#10926) goes only where the title already states what landed. **No file is renamed.** ## Readers - **Needles:** none. The 17 ledger reasons are data the file's own assertions read for their class phrase, record id and editor names, all kept (above); none reads an id. The one declared expect message is a failure message (the second argument of `expect`), not an expected value. `notification-event-migration-retirement.test.ts` reads the `NOTIFICATION_EVENT_MIGRATION_ID` docblock for a claim matrix's absence and for `RETIRED` / `os migrate` / `boot-time invoker` / `files-to-references`, none of them an id. No title or message in the group is matched against a source docblock or another file's text. - **Test-name filters:** none. No tracked script, workflow or package config passes `-t` / `--testNamePattern` to vitest; the one vitest `-t` hit is a README example under `packages/qa/dogfood` filtering its own fixture. - **Snapshots:** none. No `__snapshots__` directory is tracked under `packages/spec`, and none of the 16 files calls a snapshot matcher. - **Projects:** all 16 files run in `local`; none is in `packages/spec/vitest.repo-tests.json`. The base-versus-head run below takes both projects anyway. - **By substring:** every old literal, its id-bearing fragment and a window around each id (193 needles) was searched with `git grep` at the base, across the tracked tree outside its own file. No gate, doc, filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test reads one. The 26 hits are 16 published CHANGELOG lines, which quote "form ↔ Zod reconciliation (objectstack-ai#3786)" and "strict from birth (objectstack-ai#4001)" as release text, and 10 sibling hits among the five `(objectstack-ai#15679)` titles of this group, all rewritten here. - **One code comment quotes a title:** `tracing.test.ts:560` says "a describe headed `Span.duration carries its unit`". The new title keeps that prefix. ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. This stage declares the 18 lines named above. - **Result:** 16 of 16 files SAME on all three legs, with the per-file counts predicted in writing before any edit (2026-10-06T13:25:38Z). - **Totals:** 63 changed string leaves in 63 literals: 45 titles and 18 declared. The diff's `+` and `-` lines are exactly the 63 planned lines as multisets, and every file keeps its line count. - **Controls (14 of 14 as predicted on the first run, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; an `it.each` row given an id VIOLATION; an undeclared expect message changed VIOLATION; a title re-split into a `+` chain DIFF; a declared ledger reason reverted to base SAME; the declared deferral reason given a new id VIOLATION; the declared expect message given a new id VIOLATION; a template-literal title given a new id VIOLATION. - **Templates and tables:** no `.each` title, no `$name` / `%s` placeholder and no table row changes. **Test counts:** the 16 files were run at the base, before the edit, and at the head, in the same worktree, with `--project local --project repo`. Both sides read 747 tests in 16 files, all passed, with the same count and status sequence per file in 16 of 16. 302 full test names change, and each changed name equals the base name with the planned replacements applied: 0 mismatches. One full name repeats on each side, the same one: an `it.each` pair under "translation unknown-key strictness" that already printed alike at the base. No head name carries `#` plus digits (302 base names did). No source escape sits in a planned anchor, so the comparison tool met none. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 16 touched files are in it, and no `*.test.ts` at all (`files[]` ships `src/**/*.zod.ts`, not tests). The controls `src/system/translation.zod.ts`, `src/system/metrics.zod.ts` and `dist/index.mjs` are in it. - In the built `dist/`, two new phrases and an old one each read in 0 files. The control `Unrecognized key` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `320cf687c7`) - `pnpm turbo run build` over all packages: 71 / 71, through the shared verify lock (turbo exit 0, recorded to a file). - `@objectstack/spec`: - `vitest run --project local`: 619 files, 18485 passed, 1 todo. - `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 16 group files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date, against the `dist/` the build above wrote. - **Gates:** `dispatch-gates --commands` derived 79 families, the same 79 as stage 26. All 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The five roster families marked as sharing a directory with this diff (`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) each exit 0. - **ESLint, a proven narrowing:** `--no-inline-config` over the 16 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 16 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 126 changed lines (+63 / -63). - A control-byte scan over the 16 changed files finds none. ## `main` since the base Re-fetched just before this PR opened, `origin/main` was one commit past the base (`6befe19c6e`, objectstack-ai#21990). It touches 4 files in `packages/metadata-protocol` and `.changeset/`, none of the 16 and none under `packages/spec/src/system/`, so `main` was not merged. `git merge-tree` onto `6befe19c6e` is clean, and none of the 4 open PRs touches any of the 16 files. ## Acceptance notes - **Same-id test titles in other packages** stay: 37 lines in 14 packages (`plugin-security` 9, `lint` 5, `cli` 4, `objectql` 4, `service-settings` 4, `core` 2, `spec/scripts` 2, and one each in `metadata-protocol`, `metadata`, `plugin-approvals`, `qa/dogfood`, `sdui-parser`, `service-automation` and `service-i18n`), each package's share under the objectstack-ai#20513 lane children. Examples: `plugin-security/src/permission-denied-user-copy.test.ts`'s four `objectstack-ai#7414 —` describes and `metadata/src/migrations/notification-event-migration-retirement.test.ts:54`'s `[objectstack-ai#16194]` twin. No same-id title is left in this card's own census. - **Kept record ids in the ledger reasons:** `5861442317` (held by the file's own `toContain('5861442317')` assertion) and `5755653853` are GitHub comment ids of ruling records, outside the gate's three-to-five-digit pattern, not tracker numbers. - **Code comments with live ids** remain in these files, among them the `fieldGroups` / `inlineColumns` / root-coordinate ledger banners in `metadata-form-zod-reconciliation.test.ts` (six comment lines still say "5861442317, objectstack-ai#19332"), the `// objectstack-ai#7646 —` banner in `translation.test.ts` and the `objectstack-ai#17785, ruling A on objectstack-ai#15939` block in `tracing.test.ts`. Code comments are not this card's share. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Implements two core AI protocols: conversation state management with token budgeting, and comprehensive cost tracking with multi-level budget enforcement.
Conversation Protocol (
conversation.zod.ts)Multi-turn conversation state with token-aware context management:
Cost Protocol (
cost.zod.ts)Granular cost tracking with hierarchical budget enforcement:
Both protocols export TypeScript types derived from Zod schemas for runtime validation and type safety.
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.