Skip to content

feat(service-settings): warn when a settings value misses the format its type declares (18.x half, 「甲」) - #25001

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24882-settings-format-warning
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24882-settings-format-warning

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #24882. This PR is the 18.x warning half only; refusing these values is the next major's half, which the domain:services seat files when this lands.

Clause-②: no (warning only: every save and every OS_* override accepted before is still accepted, with the same value stored or served, the same status and the same errors)

What changes

ADR-0007's specifier table declares a value format for four types: email / url / phone take "format validation", and color takes a "hex string". Nothing judged it. PUT /api/settings/branding stored a Logo URL of not a url and answered 200, and an OS_* override was served unchecked.

packages/services/service-settings/src/settings-service.ts now judges that format at both doors through one shared helper, firstFormatMiss(type, value). The helper returns a verdict that carries the ADR-0114 code the refusal will use (invalid_email / invalid_url / invalid_phone / invalid_format). Both call sites report a miss and keep the value:

  • Save door (validatePatch). The format branch runs after every refusal family, so the errors a refused patch carries are exactly what they were. A miss is collected, never pushed to errors. When the patch as a whole is accepted, one warn line is logged per key, naming the namespace, key, type, the expected format and the value's length.
  • Env door (effectiveEnvOverride). The format is judged last, only on an override that every refusal family has already let through. A miss draws one warn line, deduplicated per (variable, value) like the existing rejection report. The override stays in force: it is served with source: 'env' and it still locks the key. auditEnvOverrides also walks format-typed keys, so the line arrives at boot rather than on a first read.
  • The value is never printed in either line, for any key. That covers the secret-bearing case, and it keeps an environment variable's value out of the log.

The grammars are the platform's existing ones for the same words, not new dialects:

type grammar why this one
email local@domain with a dotted domain, no spaces the address check of plugin-email, the live reader of mail.from_email. A value without a warning is one the transport can send from. no-reply@localhost warns.
url scheme://…, a root-relative /path, data:, or blob: the record validator's url field grammar. Not http(s)-only: the reader of branding.logo_url (RuntimeConfigPlugin.logoUrl) declares "absolute or relative URL", and storage hands back /api/v1/storage/files/… for an upload.
phone digits, spaces and + ( ) - ., at least 5 characters the record validator's phone field grammar. No shipped manifest authors a phone specifier (company.phone is text).
color #rgb, #rgba, #rrggbb, #rrggbbaa CSS hex notation. ADR-0007 says "hex string", the picker emits #rrggbb, and the reader serves it as a browser theme colour.

Empty values, blank values and non-string values are not judged, matching how firstPatternMiss treats them. Surrounding whitespace is ignored because both live readers trim before use.

「甲」 reading

The standing rule (#6015, comment 6292005854), verbatim: "No conversion can carry it: 18.x ships only the loud half (a warning, an explain line, a reported event). The refusal is filed for 19.0."

The seat's derivation for this card is comment 6302777692:

  • Refusing a malformed email / url / phone / color save narrows what the settings door accepts today.
  • Stored values live in sys_setting (runtime state). An OS_* override is a deployment environment value. No ADR-0087 conversion can rewrite either.
  • So 18.x warns.

This PR implements exactly that:

  • No FieldError, no 400 and no new 409 anywhere.
  • The response shape is unchanged.
  • Every stored and served value is byte-identical to before.

The pins below assert all of this through the real route. The refusal half reads miss.code from the verdict, so turning the warning into a FieldError changes the two call sites and nothing else.

Where the warning reaches. Measured at 7adfaec2a:

  • The PUT answer is { values } only.
  • setMany returns a record of ResolvedSettingValue keyed by setting, which has no advisory member.
  • The settings:changed event (SettingsChangeEventSchema) and the two audit sinks carry no advisory member either.

So the server log, through the service's existing logger.warn (wired from ctx.logger; console.warn without one), is the only surface reachable without a new response member. Whether a log-only warning is loud enough for an admin saving in Setup is put to the seat as a decision in the report. The console half (constraint validation on Save) is objectui's, beside objectstack-ai/objectui#13239.

Tests

New file packages/services/service-settings/src/settings-format-warning.test.ts, with 31 cases. The save door is driven through the real route (registerSettingsRoutes over a real SettingsService, as settings-routes.test.ts does). Status starts at 0, so 200 is asserted rather than defaulted. The env door is driven through get, getNamespace and the PUT lock.

  • Per type, on every shipped key with the type: branding.logo_url, company.website, branding.accent_color, company.primary_contact_email and mail.from_email, plus a synthetic phone key.
    • A malformed save answers 200, is stored as written, and draws exactly one warning naming namespace, key and type, without the value.
    • A well-formed save draws none.
  • Per type at the env door:
    • A malformed override is in force (source: 'env', locked: true, a save still answers 409). It draws exactly one warning, at boot, which re-reads do not repeat.
    • A well-formed override draws none.
  • A secret-bearing email key at both doors: the value and its fragments never appear in the warning.
  • Precedence: a value refused for maxLength carries only the max_length FieldError. A batch refused for one key stores nothing and warns about nothing.
  • The other shapes each grammar admits (relative, data: and http: logos, 3/4/6/8-digit hex, a padded email) draw no warning. Empty and blank values draw no warning.
  • The synthetic fixture parses under SettingsManifestSchema.

Readings on the final head b3b236b77, each through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0):

  • pnpm --filter @objectstack/service-settings run typecheck: exit 0. The program includes the new test (tsc --listFiles, 57 test files).
  • pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2: 57 files, 960 tests passed.
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 over auth-settings-ordering.pin, auth-settings-seeded-boot.pin and auth-settings-refusal-isolation: 3 files, 13 tests passed. These import service-settings from source.

Ablations

Each leg ran on b3b236b77 with the implementation committed. The mutation went through node scripts/ablation-replace.mjs in WRAP mode: the anchor hit 1 time and fell to 0, and the blob changed on disk. A trap-armed wrapper then ran the format pins and restored the file. The restore is proven with blob == HEAD (f3caa344f9a9) and an empty git diff HEAD. The tests import the service from source, so no dist/ is involved.

leg mutation result
A1 the judge accepts everything (return null) 14 red: every malformed-value pin at both doors, the secret-bearing ones included (expected [] to have a length of 1 but got +0)
A2s the save door refuses a miss (errors.push + continue) 8 red: the 7 save-door malformed pins (expected 400 to be 200), and the precedence pin ([ 'support_phone', 'docs_url' ] vs [ 'docs_url' ])
A2e the env door rejects a miss (return null) 7 red: every env-door malformed pin (expected null to be …, expected [] to have a length of 1)
A3 the judge flags every value 13 red: every well-formed pin at both doors, and the other-shapes pin
A4 the warning echoes the value 14 red: every malformed pin at both doors, the two secret-bearing pins included (expected … not to contain …)
A5 the value is judged untrimmed 1 red: the other-shapes pin (padded email)

Gates

Gate commands were derived from this worktree with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) on b3b236b77: 67 commands. Each ran locally, with its exit code captured before any pipe. The readings are in the os-dev report on the card. CI owns the families outside that list.

Acceptance notes

  • Grammar mirrors. The email grammar is now spelled in four places: the record validator, the rule validator, plugin-email and this file. The url and phone grammars are spelled in two places each, and the record validator's and rule validator's phone grammars already differ. No shared predicate exists in packages/spec, and nothing pins the copies equal. Owner: none.
  • Non-string values on string-persisting specifiers. ADR-0007 says "Persists: string". A number written to logo_url is stored as given and dropped by the branding reader. That is a type constraint, not a format, so it is left alone here exactly as pattern leaves it. It was not measured at a public door. Owner: none.
  • company.phone is authored as type: 'text' with an "E.164 recommended" description, so no phone format applies to the one phone-like shipped key. The manifests are outside this card. Owner: none.

Generated by Claude Code

…format

ADR-0007 declares a format for the email, url, phone and color specifier
types. Both doors now judge it through one shared helper (firstFormatMiss)
and, under the standing rule 甲, report a miss instead of refusing it: a
save is still accepted and stored, an OS_* override is still in force, and
one warn line names namespace, key and type without ever printing the value.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
Both live readers (the mail transport's address check and the branding
read) trim before use, so surrounding whitespace is not a format miss.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 15 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_email (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), invalid_format (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), invalid_phone (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), invalid_url (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line))
  • content/docs/api/error-handling-client.mdx (via invalid_email (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line))
  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/kernel/runtime-services/settings-service.mdx (via invalid_format (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via invalid_email (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via invalid_email (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), invalid_phone (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), invalid_url (literal, a string literal in DECLARED_VALUE_FORMATS; a string literal in FormatMiss; a string literal on a changed line), /api/v1/storage/files (route, a path literal in a comment on a changed line))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7532ea3c0893a08a518739aebfe7c0751211f53d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7bf9c4a4fef534c827c6e1affc560661faa152b0 — the merge of head b3b236b772ec04d79c6083e8f25ce67258d42b3c into base 7532ea3c0893a08a518739aebfe7c0751211f53d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7bf9c4a4fef534c827c6e1affc560661faa152b0 && git checkout 7bf9c4a4fef534c827c6e1affc560661faa152b0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7532ea3c0893a08a518739aebfe7c0751211f53d b3b236b772ec04d79c6083e8f25ce67258d42b3c && git checkout -B drift-repro 7532ea3c0893a08a518739aebfe7c0751211f53d && git merge --no-ff b3b236b772ec04d79c6083e8f25ce67258d42b3c

node scripts/docs-audit/affected-docs.mjs --json 7532ea3c0893a08a518739aebfe7c0751211f53d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7532ea3c0893a08a518739aebfe7c0751211f53d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24882 (its 18.x half) · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T23:35Z · head b3b236b772.

  1. Form:
    • The first line is Part of #24882: the 19.0 refusal stays on the card. The seat's grep finds no closing keyword beside an issue number.
    • Clause-②: no is at line start in the body and in the service-settings changeset (patch), with its reason.
    • 3 files, +571/−3.
    • Path face: check-governed-merges --pr 25001 reports 0 of 3 paths governed. Ordinary queue landing.
  2. Premise and fix:
    • One shared judge, firstFormatMiss(type, value), checks the format ADR-0007 declares for email / url / phone / color, at both doors:
      • the save door: validatePatch, judged after every refusal family;
      • the env door: effectiveEnvOverride, judged last on an in-force override, with auditEnvOverrides walking format-typed keys so the line arrives at boot.
    • A miss is never refused: the save answers 200 and stores the value, and the override stays in force (source: env, locked).
    • Each miss logs one warn naming the namespace, key and type, never the value.
    • The verdict carries the ADR-0114 code the 19.0 refusal will use, so that half changes the two call sites only.
    • Seat-read region: the module-level helpers, the env-door report beside reportRejectedEnvOverride, and one line in auditEnvOverrides. No organization predicate and no rung resolution: C8's regions are untouched (epic note 6302761656).
    • Pins: 31 in settings-format-warning.test.ts, through the save door and the env door, including secret-bearing keys whose warning never carries the value.
    • Ablations A1–A5 each went red as predicted and were restored to the HEAD blob f3caa344f9a9:
      • judge accepts all: 14 red;
      • save door refuses: 8 red;
      • env door rejects: 7 red;
      • judge flags all: 13 red;
      • warning echoes the value: 14 red;
      • untrimmed: 1 red.
  3. Gates:
    • CI on b3b236b772, latest run per check name: 41 checks, 34 success, 7 skipped, 0 failed.
    • Every skip is on the roster in scripts/pm/check-expected-skips.mjs (read from the file): the 18.x upgrade harness, Packed-tarball smoke, Dogfood Verify CLI, Build Docs, Temporal Conformance, Console Pin Gate and the Dogfood Regression Gate matrix.
    • Lint & Repo Gates and TypeScript Type Check are both success.
    • Local: 67 derived, 67 run, 0 NOT-MEASURED. service-settings: 57 files, 960 passed; typecheck exits 0. plugin-auth's three settings-ordering pins pass.
  4. Accepted deviations:
    • The grammars follow the live readers, not the dispatch's suggested route:
      • url admits root-relative paths and data: / blob:, because branding.logo_url's reader serves a relative path;
      • email needs a dotted domain, as plugin-email's transport does;
      • color admits #rgba / #rrggbbaa;
      • values are judged trimmed.
    • The warning never prints the value for any key, not only secret-bearing ones.
    • Save-door warnings fire per accepted save. Env-door warnings are deduped per variable and value, like the existing rejection report.
    • Prose read sentence by sentence against the diff: the changeset's Save, Environment override and value-never-printed bullets, the four grammars, and its Unchanged paragraph.
  5. Out of scope:
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it on this record. After the merge, service-settings: a value of type email, url or color is saved without the format check ADR-0007 declares (Branding's Logo URL accepted "not a url" with 200) #24882 moves to pm:on-hold with Restart-when: the 19.0 line, carrying the refusal: one FieldError per key, with the codes the verdict already carries.

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.

2 participants