fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude
, '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(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way - #14355

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates
Sep 2, 2026
Merged

fix(cli): os generate flow scaffolds a flow os validate accepts, and a test that keeps it that way#14355
os-trump merged 3 commits into
mainfrom
claude/issue-14087-flow-scaffold-validates

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#14087

The defect, reproduced before anything was edited

os g flow my_flow wrote a file os validate refused. Measured on origin/maind63c8a2, scaffolded for the name probe_thing and put through the same steps Validate.run() performs — four refusals in one parse, verbatim:

flows.0.nodes.0.label: Invalid input: expected string, received undefined
flows.0.nodes.0: Unrecognized key(s) on this flow node: `name`, `next`.
flows.0.edges: Invalid input: expected array, received undefined
flows.0: Unrecognized key(s) on this flow: `trigger`. Did you mean `trigger` → `type`?

Both halves of the card hold, and the measurement adds a third the card did not name: edges is a REQUIRED key on FlowSchema and the template emitted none. The scaffold also declared one node and pointed its next at a node it never wrote.

The fix

packages/cli/src/commands/generate.ts, the flow template only. The trigger binding moves to where AutomationEngine.resolveTriggerBinding actually reads it — the START node's config:

type: 'record_change',status: 'draft',nodes: [{id: 'start',type: 'start',label: 'Start',config: {objectName: 'probe_thing',triggerType: 'record-after-write'}},{id: 'end',type: 'end',label: 'End'},],edges: [{id: 'e1',source: 'start',target: 'end',type: 'default'}],

The card's spelling of the vocabulary was checked rather than copied: record-after-write is real (it is VALID_RECORD_TRIGGER's grammar in packages/lint/src/validate-flow-trigger-readiness.ts, and 'write' is the create-OR-update token from #3427), while events: ['after_insert', …] exists nowhere on the current surface. type moves from 'autolaunched' to 'record_change' because that is what the retired trigger.type block declared this flow to be, and it is the declaration rule 1f gates.

status deliberately stays 'draft'. Arming is the author's decision and os validate says so in an advisory; changing it would move the runtime behaviour of every generated app, which is outside what this card claims.

Measured on the fixed scaffold: 0 error-severity findings, 2 advisoriesflow-trigger-unknown-object (the objectName placeholder is not an object the fresh stack defines — the prompt to replace it) and flow-draft-status-ambiguous (the status decision above). os validate exits 0.

The scaffold-validates test

packages/cli/test/generate-scaffold-validates.test.ts. It loads each scaffold the way os validate loads authored TypeScript — through bundle-require with BUNDLE_REQUIRE_EXTERNALS, the same call loadConfig makes — and then re-runs the two steps Validate.run() performs on the result: normalizeStackInput + the unknown-key lints + ObjectStackDefinitionSchema.safeParse, then runAuthoringRules('validate') gating on the error half.

Both layers are load-bearing, and the second is the half a schema-only assertion would miss: a flow node's config is an OPEN slot by design (ADR-0018), so FlowSchema cannot judge the trigger vocabulary at all. A start node carrying triggerType: 'record_change' parses green and is caught one layer later by validate-flow-trigger-readiness. Asserting the schema alone would let this scaffold's own trigger token drift back to a spelling that never fires.

Nothing in the file is restated: the roster comes from GENERATOR_SCAFFOLD_TARGETS (built from GENERATORS itself, now carrying each entry's generate), and the stack collection each artifact lands in comes from singularToPlural — the map defineStack and the metadata registry already share. A generator added tomorrow is measured on the day it lands.

Red-first, then ablated

  • RED — the test at its final content, against the unmodified template: Tests 2 failed | 12 passed (14), failing with exactly the four refusals quoted above.
  • GREEN after the template fix: Tests 14 passed (14).
  • ABLATION, from the committed state: the START node's label: 'Start' mutated to name: 'Start'. Mutation confirmed on disk in both directions (injected 1, original 0) and by blob hash (3dae1933fbd28caf); the mutated tree fails 2/14. Restored with git checkout HEAD -- ABSOLUTE_PATH, proven by git diff HEAD empty plus the blob hash back at 3dae1933; restored tree 14/14. No rebuild leg is owed on either side: the test resolves the template through a relative ../src/… path vitest transforms from source, so the dist/ staleness hazard does not apply to this pair. The script carried a trap … EXIT INT TERM restore throughout.

Out of scope, filed rather than fixed

Running the derived roster is how it emerged that flow is not the only refused scaffold. Same harness, same commit: object parses and then fails security-owd-unset; view, action and app fail the parse for reasons of their own; dashboard and skill are clean. Triage fenced that census out of this card, so it is #14336 and is not touched here.

The four are recorded in the new test's KNOWN_UNVALIDATED_SCAFFOLDS — shrink-only, in the shape this repo uses elsewhere, with an anti-staleness assertion: a kind IN the ledger must still FAIL, so whoever repairs one turns this test red and deletes its entry in the same PR. It can never grow to cover a regression, because a newly-broken kind is not in it and simply fails. flow is additionally asserted absent from the ledger, so this card's own defect cannot be re-admitted by adding a line to a table.

#14337 is the other finding: FlowSchema's trigger alias prescribes a rename to type, and taking that advice cannot work. That is the diagnostic a hand-writing author still meets; it lands in packages/spec and is not addressed here.

The hot-file fence held — the migration codegen field-type switch region of generate.ts is untouched.

Verification

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

The `flow` template emitted a top-level `trigger: { type, object, events }`
block, nodes carrying `name`/`next`, and no `edges` — four refusals against
`FlowSchema`, which is `.strict()` and binds a record-change flow on the START
node's `config` (`{ objectName, triggerType, condition }`), where
`AutomationEngine.resolveTriggerBinding` reads it from.
The template now writes that shape. `generate-scaffold-validates.test.ts` puts
every generator's output through the two steps `os validate` runs — schema
parse, then the author-time rule registry — loaded through the same
`bundle-require` path `loadConfig` uses, since a node `config` is an open slot
(ADR-0018) and the schema alone cannot judge the `record-*` trigger grammar.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
)
The four `KNOWN_UNVALIDATED_SCAFFOLDS` entries were measured while
implementing this card and filed as #14336; the ledger now says so, so
whoever repairs one of those templates has a route from the entry to the
card rather than only to the failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

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

  • content/docs/api/data-flow.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/api/plugin-endpoints.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/approvals.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/flows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/hooks.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/automation/workflows.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/deployment/cli.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/getting-started/common-patterns.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx(via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/schema.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))
  • content/docs/releases/v17.mdx(via record_change (literal, a string literal in trigger; a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 23 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 e854a531abc9ee81264a17d0e0b1f41b38f6f03epackageMentionDocs.

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

objectstack generate scaffolds a flow that objectstack validate rejects — the trigger key and the events vocabulary do not exist on protocol 17

2 participants

@os-trump@claude