Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@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

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard - #38

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard
Sep 1, 2026
Merged

Correct the AGENTS.md predicate rule and add a stopgap flow-predicate guard#38
os-warren merged 3 commits into
mainfrom
claude/issue-29-flow-predicate-guard

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#29

Three deliverables: the AGENTS.md rule 4 correction, the metadata-first rule, and the stopgap walk.

Rework round 2 (head d03dcea). My first rule 4 rewrite claimed validate does not flag a bare identifier "on any surface". That was false and is corrected below — scope decides, not surface name. Details in the "What changed in rework" section at the bottom.

1. AGENTS.md rule 4 — corrected, split by expression scope

The governing fact is scope, not surface name. An expression is evaluated either with the record bound as the record namespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope). validate judges a bare identifier only in the first.

Record-scoped surfaces — enforced, and there the failure really is null. Object validation rules, field conditional rules, action visible/disabled, sharing rules and hook conditions bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires. Measured on this branch, duly_task's skip_needs_reason rule mutated to a bare status:

✗ Author-time rules failed (1 issue)
• object 'duly_task' · validation 'skip_needs_reason': bare reference `status` —
a formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression silently
evaluates to null. Write `record.status`.

Exit 1, located, corrective — and the platform's own message states the mechanism, which is what makes the rewrite principled rather than patched.

Flow node and edge conditions are the single exception. They run in flattened scope, where a bare name may genuinely be a flow variable, so collectBoundRecordReads deliberately never judges one. status == "dispatched" in a flow start condition passes validate with exit 0.

And in a flow the failure is not null either — the paragraph that survives from the first rewrite, now correctly scoped to the flow half. A bare status resolves: to the flattened field, or to a same-named flow variable seeded first that shadows it (the subtler bug — the predicate reads correctly and silently means something else). When a name resolves to nothing the engine throws (ADR-0032 §1c). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces.

Links #14089 and points at the stopgap as deletable when it lands.

A premise divergence, recorded and since confirmed by the PM. The card quotes a rule 4 containing "(pnpm validate now rejects it)". That text is not in duly's AGENTS.md — on main rule 4 was a one-liner. The PM has confirmed they were quoting the objectstack monorepo's file. The other half of the card was verbatim true (lines 44–46 claimed a bare reference "evaluates to null and hides the action on every record"), and that sentence turned out to be correct for record-scoped surfaces — it is restored under that half rather than deleted.

2. AGENTS.md rule 9 — metadata first

New rule carrying all three required parts: declarative metadata over handler code (objects, views, flows, jobs, datasets, permission sets, actions are primary; a handler is the last resort); check the platform for a declarative way before writing a handler; and if the platform cannot express it, file an issue against objectstack-ai/objectstack and say so on the card rather than quietly working around the gap.

Appended as rule 9 rather than inserted as rule 1, deliberately. Inserting at the top renumbers rules 4–8, and rule 4 is referenced by number in this card and in the dispatch prompts for #2, #7 and #11 — all in flight. Position does not encode importance in this list anyway. Happy to absorb the renumber on request.

3. test/flow-predicates.test.ts — the stopgap

Labelled at the top as a stopgap pending #14089 and written to be deleted when that lands. Its header now opens with the scope framing: it covers the only predicate surfaces the platform leaves open, which is what makes walking just dulyFlows/dulyJobs the right scope rather than a narrowing.

The bar — a bare identifier is a finding when it (1) names a declared field of the bound object and (2) names no variable the flow declares. Deliberately the same bar #14089 proposes upstream, so the two cannot disagree about what a defect is.

Region recursion is the platform's, not hand-rolled.collectFlowGraphs yields the flow's own graph plus every nested region — loop.body, parallel.branches[], try_catch.try/.catch, nested — with readable scope labels. FLOW_REGION_CONFIG_KEYS gives the "container config without its regions" view so nested findings are not reported twice; FLOW_NODE_EXPRESSION_PATHS supplies the declared predicate slots. The scoped walk this repo already had reads config.body.edges directly, which covers loop and silently misses parallel and try_catch.

The false-positive case the exemption exists for is handled, and three structural guards keep the exemption sound rather than a hole:

  • Declared variables come from flow.variables[].name plus every binder the spec declares — iteratorVariable / indexVariable / outputVariable / idVariable / errorVariable, plus an assignment node's top-level config keys, which are the author's variable names. A *Variable-shaped key the walk does not know fails a dedicated test rather than silently producing a false positive.
  • No declared flow variable may shadow a field of the bound object — this is what makes the exemption airtight.
  • Every record_change flow must bind an object this repo declares, so a flow with nothing to anchor on cannot pass by having nothing to check.

Narrowings, declared rather than hidden: the anchor is Object.keys(object.fields), so system columns (id, created_at, …) are not flagged — the spec exports no list of them and hand-copying one here would drift. A bare name that is neither a field nor a variable is never flagged.

dulyJobs is a tripwire, not a live check, and says so. Measured against JobSchema at @objectstack/spec 17.2.0, a job declares name / label / description / schedule / handler / retryPolicy / timeout / enabled and no predicate slot — its schedule is a cron envelope, not CEL, and its logic is a named handler function. Two assertions fail loudly if JobSchema grows a predicate slot, or if any job ever carries a CEL expression.

Proof it can fail — three ablation legs

Each on real metadata, each with the mutation confirmed on disk before measuring (injected and removed literals grepped, non-empty diffstat, abort otherwise — an anchor miss reads exactly like a passing ablation), each restored by an EXIT/INT/TERM trap, and the work committed first so the trap's git checkout -- restores from a real index. All three re-run on the final head d03dcea.

LegSurface / scopeMutationpnpm validateFlow guard
1flow start-node condition (flattened)record.status == → bare status ==exit 0 · ✓ Validation passed (260ms)exit 1
2flow nested loop-body edge (flattened)isBlank(vars.existing_task)… && needs_collectionexit 0 · ✓ Validation passed (327ms)exit 1
3duly_task validation rule (record)record.status == → bare status ==exit 1, located + correctiveexit 0 — correctly claims no credit

Findings from legs 1 and 2, verbatim:

duly_assignment_fanout · node 'start' config.condition:
reads 'status' bare — write record.status [status == "dispatched"]
duly_assignment_fanout · loop 'fan_out' body · edge 'fanout_e_missing' condition:
reads 'needs_collection' bare — write record.needs_collection
[isBlank(vars.existing_task) && needs_collection]

Leg 2 answers the card's region-recursion requirement on real metadata: the finding is located insideloop 'fan_out' body. Leg 3 is the rework's own check — it proves both halves of the corrected rule 4 in one run: validatedoes enforce the record-scoped surface, and the flow guard does not overclaim it. Restoration verified after each leg by absent-marker greps and git status --porcelain empty.

Six of the file's thirteen tests are a permanent in-file self-test over synthetic fixtures — the guard firing on a bare reference nested in a loop body, on the bare-string shorthand as well as the P envelope, on each field of a compound predicate; and not firing on a correctly qualified read, on a bare name that is a declared loop iterator, or on CEL builtins and string literals.

Gates

All four green on head d03dcea, working tree clean against it:

VALIDATE_EXIT=0 ✓ Validation passed (308ms)
TYPECHECK_EXIT=0
TEST_EXIT=0 Test Files 6 passed (6) Tests 188 passed (188)
BUILD_EXIT=0 ✓ Build complete (562ms)

Exit codes captured before any pipe. No changeset (this repo has none) and no objectstack.config.ts edit.

What changed in rework (6752ca7d03dcea)

Only AGENTS.md rule 4 and the test file's header comment. Rule 9 untouched; the walk itself untouched.

  • Rule 4 restructured around scope rather than surface name, with the record-scoped half enforced and flow node/edge conditions as the stated single exception.
  • The null failure model restored — it was correct for record-scoped surfaces, and the platform's own diagnostic uses the same words. My previous text implied null is never the failure model, which misled in the other direction.
  • The shadowing paragraph and the ADR-0032 §1c throw kept, moved under the flow half where they belong.
  • Test header opens with the scope framing so the file's narrow surface reads as precision, not omission.

Out of scope, filed

One upstream issue considered and deliberately not filed.FLOW_NODE_EXPRESSION_PATHS does not list config.condition or edge.condition, which looked like an incomplete predicate-slot table. Its docstring says the omission is deliberate — they are structural surfaces on every node and edge rather than declared configSchema slots, and both validators already walk them. Commit 6752ca7 corrects a comment of mine that had implied the gap.


Generated by Claude Code

… guard
`pnpm validate` resolves every `record.`/`previous.` read in a flow predicate
against the bound object, but deliberately never flags a BARE identifier —
`collectBoundRecordReads` skips them because in a flattened flow scope a bare
name may be a flow variable. AGENTS.md described the wrong failure model for
that case ("evaluates to null and hides the action"), which is not what a flow
does: the engine flattens the record's fields to top-level names, so a bare
name resolves, or throws (ADR-0032 §1c) — never silently false.
- AGENTS.md rule 4: state which surfaces the gate covers, what it does not
cover, the real flow failure mode, and link objectstack-ai/objectstack#14089.
- AGENTS.md rule 9 (new): metadata first — declarative metadata over handler
code; check the platform first; file upstream rather than working around.
- test/flow-predicates.test.ts: labelled stopgap pending #14089. Walks
dulyFlows and dulyJobs via the platform's own collectFlowGraphs (so loop /
parallel / try_catch bodies are covered), flags a bare identifier that names
a field of the bound object and no declared flow variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
`FLOW_NODE_EXPRESSION_PATHS` omits `config.condition` and `edge.condition`
deliberately — its docstring says so: they are structural predicate surfaces
on every node and edge rather than declared configSchema slots. The earlier
wording read as an omission and could have sent someone upstream with a
non-issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The previous rewrite claimed validate does not flag a bare identifier "on any
surface". Measured false: a bare `status` in duly_task's skip_needs_reason
validation rule exits 1, located and corrective, and the platform's own message
names the mechanism — a record-scoped expression binds the record as the
`record` namespace only, so a bare name resolves to nothing and the expression
silently evaluates to null.
Scope decides, not surface. Record-scoped surfaces (validation rules, field
conditional rules, action visible/disabled, sharing rules, hook conditions) are
enforced, and there the deleted "evaluates to null" sentence was correct — it is
restored under that half. Flow node and edge conditions run in flattened scope
and are the single exception; the shadowing case and the ADR-0032 §1c throw stay
there, where they belong.
test/flow-predicates.test.ts gains the same framing at the top: it covers the
only predicate surfaces the platform leaves open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:53
@os-warren
os-warren merged commit 03d6afb into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one

2 participants

@os-warren@claude