Skip to content

fix(service-settings): enforce the declared step grid on the settings write path and the env path (#6199) - #6501

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6199-settings-step-enforce
Aug 8, 2026
Merged

fix(service-settings): enforce the declared step grid on the settings write path and the env path (#6199)#6501
os-project-manager merged 1 commit into
mainfrom
claude/issue-6199-settings-step-enforce

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6199

step is the fifth of SpecifierSchema's value constraints, and the last one that was declared but never executed. After #5932 (PR #6201) landed min / max / minLength / maxLength, step still had zero read points in packages/services/service-settings/src/: superRefine does not check it, the write path does not read it, the env path does not read it.

Premise check (against origin/main, not the issue's snapshot)

  • packages/spec/src/system/settings-manifest.zod.ts:237 still declares only step: z.number().optional().
  • The single reference to step under packages/services/service-settings/src/ is a declaration site, not a read: manifests/ai.manifest.ts:189 (temperature).
  • Swept this repo and objectui: the settings specifier's step has no consumer at all — no renderer writes it onto an input attribute, no service reads it. Premise holds.

Why this is a value constraint and not a pure UI hint

The issue offered two readings. The one that settles it is the schema's own: step is declared under the same "numeric bounds and step" doc comment as min / max, so it was authored as a bound, and #5932's ruling — a declared bound binds — transfers with it.

The competing reading (it is only the input[type=number] arrow increment and never says other values are illegal) does not survive contact: step has no consumer whatsoever. Under that reading the key would be expressing presentation for a renderer that does not exist. That is the ADR-0049 hole, not a UI affordance.

Implementation (same shape as #5932 — the family's fifth member)

Anchor convention

A value must lie on min + k * step; when no min is declared the anchor falls back to 0. That is the HTML step-base convention, and the only reading under which the declaration means what it says: min: 1, step: 2 names the odd numbers. Anchoring at 0 regardless would invert the specifier entirely — it would accept exactly the values the author excluded. Nothing in the repo declares a different base; the only other multipleOf-shaped rule anywhere (Zod's, mapped in rest-server.ts) is anchored at 0, which is this same convention with no min declared. constraint carries both step and (when declared) min, so a client can rebuild the grid.

Tolerance rule

onGrid(value)  ⇔  |value - nearest| <= max(|value|, |anchor|, |step|) * 1e-9
where  nearest = anchor + round((value - anchor) / step) * step

Exact modulo is wrong. Under binary floating point 0.7 / 0.1 === 6.999999999999999 and 1.2 / 0.1 === 11.999999999999998, and both of those are values the console's own slider emits. What makes the trap dangerous is that it fires on some values of a grid and not others: on that same 0.1 grid 2 / 0.1 and 0.2 / 0.1 happen to land exactly, so a naive check looks correct until an author picks the wrong temperature.

Why relative. An absolute slack scales wrongly with the operands: 1e-9 would be a third of a step wide at step: 1e-6, and tighter than one ULP at max: 1048576 (ai.max_tokens).

Why 1e-9. It sits deliberately between the two errors it must separate. A double carries about 2.2e-16 of relative precision, so a handful of arithmetic steps accumulate about 1e-15 — six orders of magnitude below this bound. A genuine off-grid value misses by a fraction of a step: 0.15 on a 0.1 grid misses by 0.05, which is 3e-1 relative — eight orders of magnitude above it. Nothing real lands in the gap.

Compared in the value domain, not the multiplier domain. |k - round(k)| would measure the error as a fraction of a step, so its meaning would change with the grid's fineness — while the floating-point error is a property of the numbers.

The direction of the residual doubt is deliberate. This gate tightens a path that accepted everything yesterday, so where the arithmetic genuinely cannot tell (a magnitude at which the grid is finer than the double's own spacing) it accepts. Rejecting a legitimate write is the expensive mistake; letting one absurd-magnitude value through is not.

Defensive posture for a non-positive step

step: 0 (anchor + k * 0 is a single point), a negative spacing and a non-finite value all record no grid, the same disposition an option-bearing specifier with no table already gets: nothing to enforce, unchanged behaviour, never a refused write. This matches #5204's registration posture — registration REPORTS, it never refuses — and there is nothing to report here: a manifest declaring an impossible grid refuses no write and misconfigures no deployment; it merely fails to constrain, which is exactly where every specifier without a step already sits. A window declared beside a bad step is unaffected (min: 0, max: 10, step: 0 still enforces the window).

Known consequence, accepted at ruling time

The repo's only step declaration is ai.manifest.ts's temperature (min: 0, max: 2, step: 0.1). Under enforcement 0.15 is refused. That is the declaration binding as written, not a defect of this gate. Whether that declaration itself is right (if 0.15 should stay legal, the manifest should declare a finer step or none) is the manifest owner's question — this PR does not touch it.

Tests

pnpm --filter @objectstack/service-settings test286 passed (15 files), 17 of them new:

  • Write path, 12 cases: off-grid refused (code + constraint + value + SETTINGS_VALIDATION); decimal multiples accepted (including 0.7, 1.2, 0.1 + 0.2); off-grid neighbours refused; min-anchored grid (odd numbers); zero-anchor fallback; window before grid; TOUCH gate; numeric strings; uncomparable values left alone; non-positive step declares no grid; encrypted specifier never echoes the value; the real ai manifest binding.
  • Env path, 4 cases: an off-grid override is ignored (value / source / locked / cascade / log wording); an on-grid override still wins and locks; reported at registration and reported once; a rejected override pins nothing.
  • envelope.conformance.test.ts, 1 case: drives the real route and asserts HTTP 400 + SETTINGS_VALIDATION + FieldErrorSchema parses + invalid_value + constraint. Both halves of the ADR-0112 envelope (code AND status) are covered — SettingsValidationError carries no status of its own, so the 400 is only observable at the route layer.

Reverse verification (direction predicted first: RED; result matches). Deleting the new step branch from firstRangeViolation turns 12 of the 17 red and leaves 5 green. The 12 reds are exactly the refusal-class cases. The 5 that stay green are exactly the ones asserting the gate must not fire (decimal multiples accepted, window before grid, uncomparable values left alone, non-positive step declares no grid, on-grid env override still wins) — those were green before the gate existed and should be.

Also run: pnpm lint (clean); turbo run typecheck (120 tasks, all green); turbo run build; @objectstack/service-sms 70 passed; @objectstack/plugin-email 302 passed.

Gates enumerated one by one from .github/workflows/lint.yml (not from memory) — all PASS: check:slot-lookup, query-options-erasure, nul-bytes, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, adr-anchors, org-identifier, authz-resolver, service-providers, route-envelope, error-code-casing, wildcard-fallthrough, meta-type-normalized, init-service-contract, durability-log-level, startup-registry-verdict, objectui-changeset, release-notes, release-body, node-version, workflow-status-functions, shard-attestation, published-files, engine-double-contract, resume-authority-declared, merge-driver, spec-parsed-alias, type-check-coverage, driver-conformance, stall-guard, skill-frame-sync, skill-compatibility, type-check-debt, i18n, i18n-coverage.

File surface

Related: #5932 / PR #6201 (the family's min / max / minLength / maxLength half), #5131 (options on the write path), #5204 (env half aligned with the write half).


🤖 Generated with Claude Code

https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei


Generated by Claude Code

`step` 是 `SpecifierSchema` 五个值约束里的第五个,也是最后一个只声明不执行的。
#5932(PR #6201)补齐 `min`/`max`/`minLength`/`maxLength` 之后,`step` 仍是零读取点。

判定取自 schema 自己的写法:`step` 与 `min`/`max` 声明在同一段 "numeric bounds
and step" 注释之下,即它是按「界」被作者写下的。另一种读法(纯 UI 步进)经核查
不成立:落地时 `step` 在本仓库与 objectui 中没有任何消费者。

- `step` 挂进 `DeclaredBounds` 与 `firstRangeViolation`,按构造同时到达写入路径
  与 env 路径(`effectiveEnvOverride` 这一个判定点)。
- 越界发码表现有的 `invalid_value`(ADR-0114);`rest-server.ts` 早已把 Zod 的
  `not_multiple_of` 映射到同一成员。packages/spec 未改动。
- 锚点取声明的 `min`,未声明时取 0(HTML step-base 约定)。
- 容差 `|value - nearest| <= max(|value|, |anchor|, |step|) * 1e-9`;精确取模在
  二进制浮点下会拒掉 `0.7`(`0.7/0.1 === 6.999999999999999`)。
- 非正 / 非有限的 `step` 声明不记录网格,永不拒写。

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

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 2:32am

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services/service-settings)
  • content/docs/plugins/packages.mdx (via @objectstack/service-settings)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/service-settings)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-settings)
  • content/docs/releases/v9.mdx (via @objectstack/service-settings)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 8, 2026 04:06
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 4afdd3e Aug 8, 2026
25 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6199-settings-step-enforce branch August 8, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

settings 的 step 是同族第五个声明但不执行的值约束键 —— 先要一个「它算不算值约束」的判断

2 participants