fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses - #14517

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status
Sep 2, 2026
Merged

fix(objectql,showcase): keep the state-machine refusal's facts with an authored message, and stop the New Project wizard offering statuses it refuses#14517
hotlong merged 3 commits into
mainfrom
claude/issue-14311-wizard-initial-status

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14311

What the measurement said, and where it disagreed with the card

The triage ruling asked for two engine changes: make checkStateMachine's refusal
field-located, and put its fallback message on the built-in validation catalog.
Measured against origin/main, both were already true:

The English sentence the dogfood saw was not a fallback. It was the showcase's own
message: 'Invalid project status transition.', and an authored message deliberately
outranks the catalog. #14253 already gave authored messages a translation address
(objects.OBJECT._validations.RULE.message); the showcase simply never populated it.
So the localisation half of this card is showcase content, not engine work.

That left one genuine engine defect, in the same code path and the same class:

Engine — an authored message cost the caller the whole envelope

checkStateMachine's emitter carried constraint and value only on the built-in
branch. The moment a rule declared a message, both were dropped. Since
ValidationRuleSchemarequiresmessage on every rule, the machine-readable half
was in practice reachable only by declaring message: '' — so a normally-authored state
machine refused writes with no way for a client to learn which states are legal.

That is exactly what "a multi-step form can jump to the owning step" needs, and both keys
are already declared on FieldValidationError (mirroring FieldErrorSchema), so nothing
in the contract widens.

Envelope for the create this card describes, before and after:

 before after
{ field: 'status', { field: 'status',
code: 'invalid_initial_state', code: 'invalid_initial_state',
message: AUTHORED, message: AUTHORED,
label: '状态' } label: '状态',
constraint: { allowed: 'planned' },
value: 'active' }

packages/spec is untouched. Which writes are refused does not change — Clause-② holds:
the refusal stays a refusal and only gains the location the ledger already declared.

Showcase — the wizard, and the message channel

  • new-project-wizard.page.ts: status is dropped from the create wizard. The
    machine admits exactly one initial state (planned), so "offer only the initial
    states" degenerates to a one-option select — a question with one answer. The option
    marked default: true supplies the value server-side, and it is the declared entry
    point, so the two cannot drift. Triage ruling 1 authorises this branch explicitly.
  • project.object.ts: the rule message is reworded. One authored sentence answers
    both codes this rule raises, and the old wording described only the update half —
    a create rejected for being born active was told about a "transition" it never
    attempted.
  • translations/index.ts: all four showcase_project rules go onto the i18n: three authored display surfaces have no bundle key at all (bulk-action defs, custom validation messages, dataset labels) #14253
    channel in en + zh-CN.

The product half of the card (a create form deriving allowed values from the object's
stateMachine) is a console feature and is deliberately not built here, per triage
ruling 3. The sticky-toast defect belongs to objectui and is not touched by this PR.

Declared scope note

Translating end_after_start and spent_within_budget goes one step past the status
rule. It is the same defect class in the same file, and it is load-bearing rather than
tidying: this same wizard trips both from its budget/schedule step, so translating
only the status rule would have moved the single English sentence one step later instead
of removing it. The new pin is scoped to the object's whole rule set for that reason.

Verification (all on the final commit 120d1d066)

whatresult
pnpm --filter @objectstack/objectql exec vitest run src/validation src/engine.test.ts src/plugin.integration.test.ts src/engine-seed-required-deferral.test.ts12 files / 552 passed
pnpm --filter @objectstack/example-showcase test27 files / 372 passed
pnpm --filter @objectstack/objectql typecheckOK (check:test-typecheck verdict line)
pnpm --filter @objectstack/example-showcase typecheckexit 0
pnpm lint (whole repo, eslint . --no-inline-config)exit 0, 71s — not narrowed
check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:error-code-casing, check:nul-bytes, check:dispatcher-error-vocabularyall exit 0

Gate families re-derived after the edits with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(40 families; it takes its own change set from the merge base). The new showcase test file
is confirmed inside the app's tsc program via tsc --noEmit --listFiles (1 hit) — a
typecheck that excluded it would have said nothing about it.

End-to-end, against a real running app

Real runtime on a scratch port, seeded admin, Accept-Language: zh-CN:

A) POST /api/v1/data/showcase_project {name, account, health} -> HTTP 201, status = planned
B) POST /api/v1/data/showcase_project {name, account, status:"active"} -> HTTP 400
{"code":"VALIDATION_FAILED",
"fields":[{"field":"status","code":"invalid_initial_state",
"label":"状态",
"message":"项目的初始状态为“计划中”,此后只能按既定的状态流转变更。",
"constraint":{"allowed":"planned"},"value":"active"}]}

That is the deliverable's proof as triage worded it — creating with planned, refusing
active with a field-located, zh-CN message — and it exercises the real i18n service
resolving the new bundle key, which the unit tests (which mock translate) cannot.

Reverse verification

The engine fix was committed first, then ablated: the two constraint / value spread
lines were deleted and the mutation confirmed on disk by marker count (1 -> 0) and by
git hash-object differing from the HEAD blob.

  • objectql unit tests (source-resolved): 2 failed | 184 passed — red, as predicted.
  • showcase suite: objectql is not aliased to source for this app, so it resolves
    through dist/. The mutated dist/ was rebuilt before the run: 1 failed | 4 passed.

Restore leg proved rather than assumed: git checkout HEAD -- path (never a bare
git checkout --, which restores from a possibly-polluted index), restored blob hash
byte-identical to the HEAD blob, git diff HEAD empty, whole-tree git status --porcelain
clean, and dist/ rebuilt and re-verified with ablation-dist-preflight finding the
marker present in 4 executable files. (The first preflight marker I chose was a source
spelling that only survives into sourcemaps; the tool caught it and the check was redone
with a marker that survives transpilation.)

Declared narrowing — verification ran UNLOCKED

scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no
usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and
a stock macOS does not ship it), so the commands were run through the wrapper but without
the lock — a declared narrowing, not a silent one. No serialization guarantee held for
these runs, nor for any sibling agent in this container while they ran. The wrapper's own
verdict line on every run: UNLOCKED (declared) · no usable flock on this host, so the shared verify lock was NEVER taken and NOTHING was serialized.

Generated by Claude Code

hotlongand others added 3 commits September 2, 2026 17:37
…t has an authored message
`checkStateMachine` emitted the full field-error envelope only when the rule
left its `message` empty; declaring one dropped `constraint` and `value`. The
spec REQUIRES `message` on every rule, so the machine-readable half was
reachable only via `message: ''` — a create form could not learn the legal
`initialStates` without parsing the author's prose.
The showcase's New Project wizard is the demo of that gap: it offered all five
project statuses on create while the machine admits only `planned`, so four
picks were dead ends answered three steps later — in English, because an
authored message is emitted verbatim unless the bundle carries
`objects.<o>._validations.<rule>.message` (#14253).
- objectql: the authored branch carries `constraint` / `value` too.
- showcase: drop `status` from the create wizard (the `default: true` option
supplies the machine's own entry point), reword the rule message so it is
honest for both refusal codes, and put it on the translation channel in
`en` + `zh-CN`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion channel
The New Project wizard can also trip `end_after_start` and `spent_within_budget`
from its budget/schedule step, so translating only the status rule would have
moved the single English sentence one step later rather than removed it. The
pin is scoped to the object's whole rule set for the same reason.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 16 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 a40c0f966ef71739fcaecf0c2e4d671d3e960d34packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 — the merge of head 120d1d0662bfe290d8ffd86bc1b9f07881010007 into base a40c0f966ef71739fcaecf0c2e4d671d3e960d34, 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 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4 && git checkout 8abe29c56edf5cd4f5bbd77c10ccacecf5c0b4a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a40c0f966ef71739fcaecf0c2e4d671d3e960d34 120d1d0662bfe290d8ffd86bc1b9f07881010007 && git checkout -B drift-repro a40c0f966ef71739fcaecf0c2e4d671d3e960d34 && git merge --no-ff 120d1d0662bfe290d8ffd86bc1b9f07881010007
node scripts/docs-audit/affected-docs.mjs --json a40c0f966ef71739fcaecf0c2e4d671d3e960d34

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlong
hotlong marked this pull request as ready for review September 2, 2026 16:16
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit bbbac0fSep 2, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14311-wizard-initial-status branch September 2, 2026 18:06
os-trump pushed a commit that referenced this pull request Sep 2, 2026
…#14517
PR #14517 (the New Project wizard) landed on `main` after this branch measured,
and it touched two of the showcase files these numbers are computed from. The
collision is exactly on the family this branch introduces, so the number could
not stand: `examples/app-showcase` re-derives 454 -> 450.
The move is DOWNWARD and fully attributed. #14517 added a `_validations` block
to the showcase bundle carrying zh-CN for four rule messages —
`project_status_flow`, `project_health_progression`, `end_after_start`,
`spent_within_budget` — and those are exactly the four findings that disappear:
showcase `_validations` findings 11 -> 7 (-4, no key added)
showcase bulkActions / datasets 18 / 32 (unchanged)
pre-existing count 393 (unchanged)
No new key appeared. `project_status_flow` already existed as a rule, so
#14517's rewritten message text moved its content, not its address.
Those four are the first real consumers of the key shape #14253 declared and
this branch scaffolds: an author-written refusal that now reaches a zh-CN caller
in Chinese, which the coverage gate can only see because the walk visits the
family.
Re-derived with `node scripts/check-i18n-coverage.mjs --update`, never by hand.
Every other line is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

1 participant

@hotlong