Skip to content

Fix pnpm version mismatch in GitHub Actions workflows - #134

Merged
hotlong merged 3 commits into
mainfrom
copilot/fix-action-step-issue-again
Jan 25, 2026
Merged

hotlong merged 3 commits into
mainfrom
copilot/fix-action-step-issue-again

Conversation

Copilot AI commented Jan 25, 2026 •

Copy link
Copy Markdown
Contributor

CI was failing because package.json specifies pnpm@10.28.1 while all workflows used 10.28.0, triggering ERR_PNPM_BAD_PM_VERSION in pnpm/action-setup@v4.

Changes

Updated pnpm version to 10.28.1 in:

  • .github/workflows/ci.yml (test and build jobs)
  • .github/workflows/lint.yml (typecheck job)
  • .github/workflows/validate-deps.yml (validate job)
  • .github/workflows/pr-automation.yml (changeset-check job)

All workflow files now align with the packageManager field in package.json.

Original prompt

引用: https://github.com/objectstack-ai/spec/actions/runs/21325209448/job/61381136149#step:4:1


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel

vercel Bot commented Jan 25, 2026 •

Copy link
Copy Markdown

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

Project Deployment Review Updated (UTC)
spec Ready Ready Preview, Comment Jan 25, 2026 2:51am

Request Review

Copilot AI and others added 2 commits January 25, 2026 02:47
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix action step issue in GitHub workflow Fix pnpm version mismatch in GitHub Actions workflows Jan 25, 2026
Copilot AI requested a review from hotlong January 25, 2026 02:51
@hotlong
hotlong marked this pull request as ready for review January 25, 2026 02:51
Copilot AI review requested due to automatic review settings January 25, 2026 02:51
@hotlong
hotlong merged commit 9a30e52 into main Jan 25, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns GitHub Actions workflows’ pnpm version with the repository’s packageManager setting to prevent CI failures caused by ERR_PNPM_BAD_PM_VERSION.

Changes:

  • Updated pnpm/action-setup@v4 version from 10.28.0 to 10.28.1 across CI, lint/typecheck, dependency validation, and PR automation workflows.
  • Ensures workflow pnpm version matches package.json (pnpm@10.28.1).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
.github/workflows/ci.yml Updates pnpm version for both test and build jobs to match packageManager.
.github/workflows/lint.yml Updates pnpm version in the typecheck job to match packageManager.
.github/workflows/validate-deps.yml Updates pnpm version in dependency validation workflow to match packageManager.
.github/workflows/pr-automation.yml Updates pnpm version in changeset-check job to match packageManager.

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…Ms (step 1 of ruling A on objectstack-ai#18115) (objectstack-ai#18238)

Fixes objectstack-ai#18122

Step ① of three on ruling A of objectstack-ai#18115 (decision batch objectstack-ai#134 item 1): the
declared shape the unit-in-key census will read. It adds the two closed
duration types and converts nothing — step ② (objectstack-ai#18123) teaches the gate
to read them, step ③ (objectstack-ai#18124) converts the rows.

> **A** 声明式:引入与 `EpochMs` 同款的闭合时长类型(`DurationMs` /
`DurationSeconds`,单位集合由 6 个真时长行决定);体检只认「用了时长类型,或键名带单位」的键;17
个无单位数自然出列,6+2+≥2 个真时长各取类型或改名;名字表退为提示。

## Where the types landed, and why a sibling file

`packages/spec/src/shared/duration.zod.ts`, a new sibling re-exported
from `src/shared/index.ts` exactly as `epoch.zod` is — not appended to
`epoch.zod.ts`. That file is the INSTANT file: its whole doc block is
the instant-vs-duration distinction, and the gate's own source calls its
export `INSTANT_ROOT`. Putting durations inside it would make the
module's name false about its contents. The barrel re-export is what
makes the two files equivalent in published surface, which is the
property the card asks to mirror.

## Reachability — measured, not asserted

The card's "reachable from the package entry exactly as `EpochMs` is"
means the **`./shared` subpath**, not the root entry. Four readings on
this branch, the last two taken from the regenerated artifact:

```
$ git grep -n 'DurationMs\|DurationSeconds\|EpochMs' -- packages/spec/src/index.ts
(no output; exit 1 — zero hits, so the precedent is absent from the root entry too)

$ git grep -n epoch -- packages/spec/src/shared/index.ts
packages/spec/src/shared/index.ts:40:export * from './epoch.zod';

$ grep -l 'DurationMs\|DurationSeconds' packages/spec/api-surface/*.json
packages/spec/api-surface/shared.json          <- the only entry point that carries them

$ grep -c 'EpochMs\|DurationMs' packages/spec/api-surface/root.json
0                                              <- control: the root entry carries neither
```

The `api-surface/shared.json` delta is **4 rows added, 0 removed** —
`DurationMs (const)`, `DurationMs (type)`, `DurationSeconds (const)`,
`DurationSeconds (type)`. No root `src/index.ts` re-export was added.

## The refinement, and the defaults that chose it

`z.number().int().nonnegative()` for both, measured against the six
genuine duration rows — the 23 census rows that carry a unit in neither
channel, minus the 17 counts wearing a duration's vocabulary:

| row | declares today |
|---|---|
| `kernel/plugin-lifecycle-advanced.zod.ts` `shutdownTimeout` |
`z.number().int().min(0).default(30000)` |
| `kernel/plugin-security-advanced.zod.ts` `cors.maxAge` |
`z.number().int().optional()` |
| `system/metrics.zod.ts` `slideInterval` |
`z.number().int().positive().optional()` |
| `system/auth-config.zod.ts` `session.updateAge` |
`z.number().default(60 * 60 * 24)` |
| `api/contract.zod.ts` `meta.duration` | `z.number().optional()` |
| `data/field-value.zod.ts` `FileValue.duration` |
`z.number().optional()` |

- **`.int()`** — three of the six already declare it, and both rows that
carry a default default to an integer (`30000`, `60 * 60 * 24`). The
three bare `z.number()` rows are tightened by adopting it, which is the
same tightening `epoch.zod.ts` records for the sites that adopted
`EpochMs`.
- **`.nonnegative()`** — the weakest floor every declared floor implies.
One row declares `.min(0)`, one `.positive()`, none a negative floor.
`.positive()` would be too strong in the other direction: `.min(0)`
admits `0`, and a zero timeout means "do not wait".
- **No third unit.** `DurationMinutes` / `DurationHours` /
`DurationDays` are absent on purpose — the unit set is derived from the
conversion population, never declared ahead of it.

Author state and parsed state coincide (no `.default()`, no
`.transform()` on the types themselves), so there is deliberately no
`*Parsed` synonym, and the isomorphism is pinned as ADR-0122 requires:
`Iso873` / `Iso874` in `type-alias-convention.pin.test.ts` (count 783 ->
785, both prose statements moved with it). `check:spec-parsed-alias`
reads that file as its exemption registry, so the pins are load-bearing
rather than decorative.

## The pin can actually fail — ablation

Dropping `.nonnegative()` from `DurationMs` only, proven on disk before
the run (the anchored line count went `1 -> 0`, the mutated spelling `->
1`) rather than trusted to the editor's exit code:

```
MUTATED:  Tests  1 failed | 10 passed (11)
          × DurationMs refuses a negative literal
RESTORED: Tests  11 passed (11)
```

Restore proven by blob identity, not by an exit code: `git hash-object`
returned `f02731a63112ffbee7f8263fc2fea36ec0dfc27f`, equal to
`HEAD:packages/spec/src/shared/duration.zod.ts`, with `git diff HEAD`
empty.

## Two published claims this branch made false, both repaired

Neither is scope creep — both are existing published artifacts that my
new module invalidated, and both gates went red:

- **`packages/spec/llms.txt`** (hand-kept, no generator, ships inside
the npm tarball): the `shared` row said 14 schemas against 15 on disk
and the section heading said 199 against 200. The row's **prose** was
corrected alongside its number — it now names the duration vocabulary
beside the epoch instant — because the gate is explicit that rewriting a
count without re-reading its row turns a loud staleness into a silent
lie.
- **`content/docs/getting-started/quick-reference.mdx`**: the `[total]`
half of "Shared Protocol (5 of 9 schemas)". Only M moved, to 10. The
table is a curated subset and N < M is its normal state, so the duration
page is left unlisted exactly as the epoch page beside it already is —
the gate names adding a row as a decision rather than a fix.

## Local verification — every command, at `4a04e07eac`, worktree clean

| command | exit | verdict |
|---|---|---|
| `pnpm lint` | 0 | whole repo, `eslint . --no-inline-config` — not a
narrowing |
| `pnpm --filter @objectstack/spec test` | 0 | `Test Files 478 passed
(478)` · `Tests 13622 passed (13622)` |
| `pnpm --filter @objectstack/spec typecheck` | 0 | test layer compiles;
the new file is not in the debt ledger |
| `pnpm --filter @objectstack/spec check:generated` | 0 | `All 15
generated artifacts are up to date` |
| `pnpm --filter @objectstack/spec check:duration-unit-keys` | 0 | **the
card's acceptance criterion** |
| `pnpm check:spec-parsed-alias` | 0 | `1446 bare z.input aliases, 785
pinned isomorphic` |
| `pnpm --filter @objectstack/spec check:llms-txt` | 0 | `97 claim(s)
re-derived` |
| `pnpm check:quick-reference-counts` | 0 | `13 section(s), every
heading matches` |
| `pnpm check:nul-bytes` | 0 | 8668 files, no raw control bytes |

The gate families were derived mechanically rather than guessed — `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after each commit until it
stopped growing at **109 commands, all 109 run**. 107 green. The two not
measured are `check:dual-build-cjs-loads` and `check:type-check-debt`:
both refused with `PREREQUISITE NOT MET` because they read every
workspace package's `dist/`, which needs `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` — a whole-farm build
this diff cannot make relevant, since it touches only `packages/spec`.
Five further gates that first refused the same way were converted to
real green readings by building `@objectstack/formula`,
`@objectstack/lint` and `@objectstack/client-react`.

**This step changes no gate behaviour, and the gate says so itself**:
`check:duration-unit-keys` reports the same 211 duration-shaped keys, 6
`EpochMs` instants and 11 `externalVocabulary` mirrors as on
`origin/main`. It recognises exactly one identifier root today —
`EpochMs` — so a key typed `DurationMs` is outside its population rather
than exempted by it, which is precisely what step ② changes.

## Changeset

`minor`, not `patch`: `Clause-②: yes` and the `./shared` entry gains
exported symbols. Not `major` — breaking changes ship as `minor` until
GA (objectstack-ai#14043), and nothing here is breaking in any case: no key is
converted, renamed or refused, and the six rows still declare exactly
what they declared before.

## Out of scope, noted not filed

- `packages/spec/src/data/driver.zod.ts:414` `idle` sits in the unitless
census but is a **pool-stat count** (`{ total, idle, active, waiting }`
from `getPoolStats`), not a duration — worth knowing for step ③'s
triage, since its name reads like a duration and its neighbours in the
census are. Carrier: objectstack-ai#18124, which reads this same census.
- `packages/spec/src/shared/connector-auth.zod.ts:38` `tokenExpiry`
describes itself as a "Token expiry timestamp" — an **instant**, so
`EpochMs` rather than a duration type. Same carrier, objectstack-ai#18124.

Neither is a defect, a contract violation or an authoring trap, so
neither is filed.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ionSeconds and two externalVocabulary mirrors (objectstack-ai#18657)

Part of objectstack-ai#18124

Clause-②: no

Step ③ of ruling A on objectstack-ai#18115 (decision batch objectstack-ai#134 item 1). Eight of the
census's ten genuine-duration rows now declare their unit through a
channel a reader actually reaches. Two rows are reported back to the
director seat instead of converted, each with the measurement that
stopped it.

⚠️ `Part of`, not a closing keyword, and deliberately: two ruled rows
are not delivered here, so a merge must not retire the card. The
director seat decides when it closes. The two are named under "Reported
back" below.

## The blocker cleared — proved by subject probe, not by card state

Card objectstack-ai#18123 (step ②) is open, but its SUBJECT is on `main`. Commit
`8ca7aafc45` — *spec(gate): check-duration-unit-keys admits by
declaration — retire the name-shape token list*, PR objectstack-ai#18486 — carries
`Part of objectstack-ai#18123` rather than a closing keyword, so the behaviour shipped
while the card stayed open. On this branch's merge base `922c75588`,
`DURATION_ROOTS` in `packages/spec/scripts/check-duration-unit-keys.ts`
already maps both `DurationMs` and `DurationSeconds`.

That answers the dispatch's open question — *does the gate stop seeing a
row that adopts `DurationMs` while its key name carries no unit, or
start refusing it?* — with a third answer: **neither**.

| | before | after |
|:--|:--|:--|
| all ten ruled rows in `--list` | absent — each declares nothing, so
none is admitted to the census | the eight converted rows are admitted
through the channel each adopted |
| offenders | 0 | 0 |
| declared duration types | 0 | **6** — equals the rows converted by
type, which is the card's own acceptance line |
| declared `externalVocabulary` mirrors | 11 | **13** |
| declared `EpochMs` instants | 15 | 15 |
| declared `dimensionless` | 0 | 0 |
| census size | 195 | 203 |

A converted row is admitted by its TYPE (or by its mirror marker) and
exempted from the key-NAME requirement; both contradiction directions
stay refusable. Nothing is refused that was not refused before.

## The population, re-derived by key + file, with the unit MEASURED per
row

The card's "likely unit" column carries its own warning and was not
copied. Each row's producer was measured; where the measurement came
back EMPTY that is stated as such rather than dressed up.

| file · key | measured unit | what was measured, and what it returned |
route |
|:--|:--|:--|:--|
| `api/contract.zod.ts` `BaseResponse.meta.duration` | ms | **No
producer exists.** `packages/rest/src` contains no `timestamp:` writer
at all, so the envelope's `meta` block is never emitted; a repo-wide
sweep of `duration:` assignments in non-test sources returns only
`Date.now() - start` sites (`cli/src/utils/config.ts`,
`core/src/qa/runner.ts`, `timer.elapsed()` = `Date.now() - start`).
Declared from the sibling channel instead: every spec key that spells a
processing time spells ms (`tracing.durationMs`, `worker.durationMs`,
`worker.avgExecutionMs`), and the schema's own test literal is `150`. |
`DurationMs` |
| `kernel/plugin-lifecycle-advanced.zod.ts`
`HotReloadConfig.shutdownTimeout` | ms |
`packages/core/src/hot-reload.ts` is the only in-repo reader and treats
it as a millisecond budget; its suite drives `120_000`, `1000` and `50`
through it. The objectstack-ai#15676 wave recorded in the migration ledger that this
sibling is "deliberately NOT renamed" with `debounceDelayMs`, so the
type route is the one the repo already chose. | `DurationMs` |
| `kernel/plugin-security-advanced.zod.ts`
`KernelSecurityPolicy.cors.maxAge` | seconds | This key IS the CORS
`Access-Control-Max-Age` header. Its TWIN — `shared/http.zod.ts`
`CorsConfig.maxAge` — is the same field and already declares `.meta({
externalVocabulary: 'CORS ...' })` with the describe "Preflight cache
duration in seconds". The live emitter,
`packages/adapters/hono/src/index.ts`, reads `OS_CORS_MAX_AGE` with a
default of `86400` and hands it to the header. | `externalVocabulary` |
| `system/auth-config.zod.ts` `AuthConfig.session.updateAge` | seconds |
`packages/plugins/plugin-auth/src/auth-manager.ts` forwards it by name:
`updateAge: this.config.session?.updateAge || 60 * 60 * 24` straight
into better-auth's `session.updateAge`, seconds. The comment above the
pair in the schema already calls BOTH names better-auth's "forwarded by
name" — only `expiresIn` carried the marker. | `externalVocabulary` |
| `system/metrics.zod.ts` `MetricAggregationConfig.window.slideInterval`
| seconds | No runtime reader. The sibling it slides across in the same
object literal is `durationSeconds`, and the schema's own fixture pairs
`durationSeconds: 300` with `slideInterval: 60`. | `DurationSeconds` |
| `system/metrics.zod.ts`
`MetricsConfig.retention.downsampling[].resolution` (class B) | seconds
| No runtime reader. Its JSDoc says "Resolution in seconds" and its
sibling in the same object is `afterSeconds`. The unit was in the JSDoc
alone — the objectstack-ai#14519 shape, invisible on the published page. |
`DurationSeconds` |
| `system/metadata-persistence.zod.ts` `MetadataLoadResult.loadTime`
(class D) | ms | **Every** producer agrees:
`metadata/src/loaders/filesystem-loader.ts` and `database-loader.ts`
write `Date.now() - startTime`; `memory-loader.ts` and
`remote-loader.ts` write literal `0`. | `DurationMs` |
| `system/metadata-persistence.zod.ts` `MetadataSaveResult.saveTime`
(class D) | ms | Same producers, same expression. | `DurationMs` |

Class B's second row and one class A-true row are **not** converted —
see below.

### Why two rows took `externalVocabulary` and not a type

The dispatch left this to measurement. For `cors.maxAge` the decisive
reading is that an identical key already exists in this spec and already
carries the marker; giving the twin a different declaration would make
one field read two ways. For `updateAge` the decisive reading is the
schema's own comment, which describes the `expiresIn` / `updateAge` PAIR
as better-auth names forwarded verbatim while only one of the two
carried the marker.

Neither route changes the accepted set, so this does not move the
declaration: `Clause-②: no` stands either way.

## Reported back rather than converted — ⛔ both are correct outcomes,
not gaps

**1. `data/field-value.zod.ts` `FileValue.duration` — the unit is not
measurable, and both closed types are hazardous here.**

- Producer: **none**. There is no media-metadata writer in this repo —
no `ffprobe`, no probe of any kind;
`ObjectQLEngine.resolveFileReferences` expands a file reference to `{
id, name, size, mimeType, url }` and never writes `duration`.
- Consumer: **none** in this repo, and none in the sibling objectui
checkout available here (its only `duration` hits are Tailwind
transition classes).
- Documentation: `content/docs/protocol/objectql/types.mdx` names
`duration` as an optional member and states no unit.
- The external convention a producer would follow —
`HTMLMediaElement.duration`, `ffprobe` — is **fractional seconds**.
`DurationSeconds` is `.int()`, so adopting it would refuse `12.34`;
`DurationMs` would contradict that convention by a factor of 1000.
- And this key is reachable by STORED data: `FileValueSchema` is the
pre-v17 inline blob that ADR-0104's dual-mode window keeps parsing, so
`.int()` narrows against rows that may already exist — a different risk
class from the rest of this tranche.

Three ways forward, all outside this card's fences: accept integral
seconds and its truncation; add a fractional-seconds unit to the
vocabulary (⛔ "no new type"); or rename (⛔ the rename branch). The
director seat sequences it.

**2. `kernel/plugin-versioning.zod.ts`
`CompatibilityMatrixEntry.estimatedMigrationTime` — the unit is HOURS,
which no type in the closed vocabulary covers.**

Its JSDoc reads "Estimated migration time in hours" and the schema's own
fixtures are `8` and `40`. There is no runtime producer or consumer.
`DurationMs` / `DurationSeconds` cannot carry hours, the card fences a
new type, and the remaining route — moving the unit into the describe —
lands the row straight on `unit-in-prose-not-in-name`, whose
prescription is a RENAME of a published authorable key
(`kernel/CompatibilityMatrixEntry:estimatedMigrationTime` is on the
authorable surface). That owes an ADR-0087 entry in
`packages/spec/src/migrations/registry.ts`, a file held by seat 1's
in-flight card. ⛔ Not opened.

## Red before green

**The gate that reverse-requires this change is `check:docs`** (with
`check:skill-refs` beside it). Before regeneration, on the converted
sources:

```
✗ 2 of 15 artifact(s) stale:
  skill references          pnpm --filter @objectstack/spec gen:skill-refs
  content/docs/references/**  pnpm --filter @objectstack/spec gen:docs
```

After `check:generated --fix`: `✓ All 15 generated artifacts are up to
date.`

**`check:duration-unit-keys` itself cannot go red for an unconverted
row**, and that is by design, not an oversight: a key that declares
nothing is not admitted to the census, so there is no verdict to fail.
What CAN go red is the declaration once it exists — both routes, proved
by ablation from the committed state, each leg with an on-disk proof and
a hash-verified restore:

```
LEG A  mutate slideInterval's describe: seconds -> milliseconds
       ON-DISK PROOF: deleted-text count 1 -> 0; injected-text count = 1
       MUTATED_EXIT=1
       [duration-unit-contradicts-schema] .../metrics.zod.ts:416 `slideInterval` —
         typed `DurationSeconds` (seconds) but the describe says ms.
       restore proof: disk=2063c360949dc2a35e7fa88501f23ec7cf76970e head=2063c36...
       RESTORED_EXIT=0

LEG B  delete updateAge's externalVocabulary marker
       ON-DISK PROOF: marker count 1 -> 0
       PIN_MUTATED_EXIT=1   (the new pin fails: externalVocabulary marker absent)
       GATE_WITH_MARKER_REMOVED_EXIT=1
       [unit-in-prose-not-in-name] .../auth-config.zod.ts:563 `updateAge` —
         describe names seconds but the key name carries no unit.
       restore proof: disk=e1fd6e449e6629f5c848f7ff8bd77d3d5aa6132b head=e1fd6e4...
       PIN_RESTORED_EXIT=0   (48 passed)
```

Leg B is the stronger reading of the two: it shows the mirror route is
*also* load-bearing, which this author expected NOT to be the case.
Removing the marker does not make the key vanish from the census,
because the describe still names the unit — so the gate refuses it.

## Controls

⭐ **LIT** — already-declared siblings, unchanged and still reading as
declared in `--list` after:

```
shared/http.zod.ts:143  maxAge  [externalVocabulary: CORS `Access-Control-Max-Age` (WHATWG Fetch)]
api/http-cache.zod.ts:82  maxAge  [externalVocabulary: HTTP Cache-Control `max-age` ...]
metrics.zod.ts:400  durationSeconds  [name: seconds] [prose: seconds]   (slideInterval's own sibling)
auth-config.zod.ts:561  expiresIn  [externalVocabulary: better-auth `session.expiresIn`]   (updateAge's own sibling)
metrics.zod.ts:847  afterSeconds  [name: seconds] [prose: seconds]      (resolution's own sibling)
```

⭐ **DARK** — things that must read zero, and do:

- `EpochMs` instants 15 → 15 and `dimensionless` 0 → 0: the census's
deliberately-excluded classes (the 10 instants, the 17 dimensionless
rows) did not move.
- The two rows reported back stayed absent from the census: 0 rows
outside the ruled population changed declaration.
- The census grew by exactly 8 (195 → 203), the number of rows
converted, with no other row entering or leaving.
- `check:authorable-surface` green with no regeneration: not one
authorable key was added, removed or renamed.

## Verification

| command | verdict |
|:--|:--|
| `pnpm --filter @objectstack/spec build` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | exit 0 — ✓ All 15
generated artifacts are up to date. |
| `pnpm --filter @objectstack/spec check:authorable-surface` | exit 0 |
| `pnpm --filter @objectstack/spec check:api-surface` | exit 0 |
| `pnpm --filter @objectstack/spec check:docs` | exit 0 |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (`tsc --noEmit` +
scripts + test layer) |
| `pnpm --filter @objectstack/spec test` | exit 0 — 484 files passed, 1
skipped; **13864 tests passed** |
| `tsx scripts/check-duration-unit-keys.ts --list` before / after | exit
0 / exit 0 — table above |
| `pnpm --filter @objectstack/spec check:duration-unit-keys` | exit 0 —
zero offenders, no baseline |
| `node scripts/pm/check-widening-tells.mjs --declaration no --diff ...`
| exit 0 — 29 files, 6 judged against a declared surface, no widening
tell |
| `node scripts/check-adr-0087-registration.mjs --base origin/main` |
exit 0 — 1 declared-breaking changeset, disposition `not-required
(no-migration-prescription)` |
| `pnpm check:nul-bytes` | exit 0 |
| 20 further derived gates (skill-refs, exported-any,
dual-source-exports, export-origins, liveness, empty-state,
variant-docs, yaml-examples, strictness-ledger, doc-frontmatter,
docs-single-h1, doc-anchors, spec-docblock-symbol-anchors,
pm-widening-tells, watch-hint-literal, merge-driver, spec-parsed-alias,
docs-spec-enumerations, quick-reference-counts, published-files,
type-source-resolution, skills-token-ratchet, changeset-fixed,
empty-changeset, changeset-no-major, test-source-alias) | exit 0 |

**NOT MEASURED, stated as such:**

- `pnpm --filter @objectstack/spec check:skill-examples` — refuses to
run without `@objectstack/client-react` built (a prerequisite this
branch did not build). Not a red on this diff; CI builds it.
- `dispatch-gates.mjs --ran` reconciles **33 of 110** derived families.
The remaining 77 are the farm, and are CI's — this is a declared
narrowing, not a silent one.
- `pnpm check:cross-package-test-inputs` exits 1 on this tree, and **it
is not this diff**: the finding is that
`packages/cli/test/init-created-files-summary.e2e.test.ts` descends
`packages/spec/dist/` with no declared glob reaching inside it. None of
this branch's 29 paths touches `packages/cli/**`, the declaration table,
or that gate's script — and that script is the subject of a separate
in-flight PR.

## Skill-surface readings (this diff touches a published skill path)

The only `skills/**` change is one generated line in
`skills/objectstack-api/references/_index.md`, written by
`gen:skill-refs` because `shared/duration.zod.ts` now has an importer
inside the API skill's reachable set.

- changed file, before → after: **50 → 51 lines** (+1, generated)
- whole published package, sum of every `SKILL.md`, before → after:
**6145 → 6145 lines** (+0)
- `node scripts/check-skills-token-ratchet.mjs`: exit 0

⚠️ **PM 更正(实测推翻本段原文;逐条读数见评论 `5714917300`)。** 原文写「`skills/**` 是受管面,所以本 PR
不走合并队列、等维护者点头」。`node scripts/pm/check-governed-merges.mjs --pr 18657`
在**装了依赖的 checkout** 上读回 `✅ NOT governed — ordinary queue landing
applies`:该行被 **objectstack-ai#11705 生成物例外**(维护者 2026-08-25 取 A)按**字节**抬起 —— 它与 `pnpm
--filter @objectstack/spec gen:skill-refs` 在本树现算的输出完全相等,且本 PR 的 29
条路径一条都没碰 `packages/spec/scripts/`(objectstack-ai#11084 共编栅栏未触发)。CI 的 `Governed Surface
Queue Guard` 同向读 success。⇒ **本 PR 走普通队列落地,⛔ 不等人批。**

## Acceptance notes

- `MetricsConfig.retention.downsampling[].resolution` gains its unit in
the JSON Schema (`json-schema/system/MetricsConfig.json`) but **not** on
the rendered reference page: `build-docs.ts` emits a nested table for an
object-valued member (which is why `KernelSecurityPolicy.cors` gains one
here) and does not for an ARRAY-of-object member, so `downsampling`
stays a one-line type signature. The declaration is correct and reaches
the JSON Schema; the page is one generator behaviour short of showing
it. Noted, not filed.
- `RestServerConfig.responseFormat.includeMetadata` and
`PluginRestApiConfig.includeMetadata` both declare "include response
metadata (timestamp, requestId)", and nothing in `packages/rest/src`
writes that block — the same absence that made `meta.duration`
unmeasurable. Declared, unimplemented. Noted, not filed.

## File surface

Every path this branch touches:

```
packages/spec/src/api/contract.zod.ts                       converted row + import
packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts   converted row + import
packages/spec/src/kernel/plugin-security-advanced.zod.ts    converted row (mirror)
packages/spec/src/system/auth-config.zod.ts                 converted row (mirror)
packages/spec/src/system/metadata-persistence.zod.ts        two converted rows + import
packages/spec/src/system/metrics.zod.ts                     two converted rows + import
packages/spec/src/api/contract.test.ts                      pins
packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts  pins
packages/spec/src/kernel/plugin-security-advanced.test.ts   pins
packages/spec/src/system/auth-config.test.ts                pins
packages/spec/src/system/metadata-persistence.test.ts       pins
packages/spec/src/system/metrics.test.ts                    pins
.changeset/18124-genuine-duration-rows-declare-their-unit.md
content/docs/references/api/{analytics,auth,automation-api,batch,contract,export,metadata,package-api,protocol,storage}.mdx   generated
content/docs/references/kernel/{plugin-lifecycle-advanced,plugin-security-advanced}.mdx                                       generated
content/docs/references/system/{auth-config,metadata-persistence,metrics}.mdx                                                 generated
skills/objectstack-api/references/_index.md                                                                                   generated · objectstack-ai#11705 例外实测抬起(NOT governed)
```

From the fenced OPEN set: **nothing**.
`packages/spec/scripts/check-duration-unit-keys.ts`,
`packages/spec/src/migrations/**`,
`packages/spec/src/contracts/automation-service.ts`,
`packages/services/service-automation/**`, `packages/types/src/env.ts`,
`content/docs/automation/flows.mdx` and
`scripts/check-cross-package-test-inputs.mjs` are all untouched.
`packages/spec/src/api/contract.zod.ts` IS touched and is the file that
is mine; the similarly-named `contracts/automation-service.ts` is not.

## 维护者速读(草稿)

**改了什么。** 规格里 8 个「真时长」字段现在把自己的单位写在了合约上:6 个改用闭合类型 `DurationMs` /
`DurationSeconds`,2 个挂 `externalVocabulary`
标记(它们的键名来自外部标准,改名会切断与标准的一一对应)。键名一个都没改,也没有增删任何可写的键。

**为什么改。** 一个 `duration: number` 字段,单位只写在代码注释里、或者哪里都没写,作者(很多时候是
AI)照着邻居的数字抄一个过来,就是一次静默的 ×1000 错误——缓存从 1 小时变成 3.6
秒,谁也不会收到报错。这一批把单位放进类型和发布出去的 JSON Schema / 参考页,让读文档的人和写元数据的人看到同一个答案。

**风险与代价(含回滚)。** 三个原本写作 `z.number()`
的字段现在只接受非负整数(`meta.duration`、`loadTime`、`saveTime`)。本仓库里测到的每一个写入方都已经在写整数,所以实测风险为零;真有人写小数,会在写的那一刻响亮报错,而不是悄悄算错。回滚就是
revert 本 PR,没有数据迁移、没有台账条目、没有墓碑。⚠️ ~~本 PR 动到 `skills/**`,属受管面,不走合并队列~~ ——
**此句已被实测推翻**(评论 `5714917300`):那一行是生成器自己 `--check` 按字节认证的纯重生成,受 objectstack-ai#11705
例外,**照常走合并队列**。

**席位意见。**(留空)

**你要做的。** ① 确认两个「退回」的行由谁排期:`FileValue.duration`
的单位在本仓库测不出来(没有任何生产者),且两个闭合类型都有风险;`estimatedMigrationTime`
的单位是「小时」,现有类型覆盖不到,而改名会动到被别人占用的台账文件。② ~~确认 `skills/**` 那一行生成内容可以随本 PR
一起进~~ —— **这一条不用你答了**:仪器已答(评论 `5714917300`),按 objectstack-ai#11705 例外自动随本 PR 进。⇒ **只剩
① 需要你的字。**

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 6007c03f Deployed Jan 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants