Skip to content

Activity variable contracts: an activity declares what it reads and writes - #495

Merged
m2ux merged 18 commits into
mainfrom
feat/493-activity-variable-contracts
Aug 23, 2026
Merged

Activity variable contracts: an activity declares what it reads and writes#495
m2ux merged 18 commits into
mainfrom
feat/493-activity-variable-contracts

Conversation

@m2ux

@m2ux m2ux commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Closes #493. Corpus half: #494 — this branch pins it.

Summary

An activity could touch any variable in a workflow and say nothing about it. Every declaration lived in the workflow file, so an activity that read one value and an activity that read forty looked identical in the file that declared them, and when one workflow included an activity another had written, the inclusion was a path and nothing more. Nothing could check that the including workflow supplied what the included activity needed, because the activity never said.

An activity now declares the session variables it reads and the ones it writes. A read is a name — the activity needs the value and does not own it. A write is a full declaration, because the writing activity is where the variable is owned, and including that activity in a workflow's graph contributes the declaration to that workflow. Being in the graph is the registration: seeding, declared-type validation and the get_workflow payload all read one merged set. A guard then holds the corpus to those declarations, in the scope each activity actually runs in, and reports zero.

The four stages

Activities declare their variables, with direction. variables.reads lists names; variables.writes carries full declarations. Direction is the part a checker can act on — ownership alone tells a reader where to look.

Inclusion contributes the declarations. The loader folds every activity's writes into the workflow's variable set, so there is no separate registration step and two activities naming one variable mean one variable. Two declarations of one name that disagree on type or default describe two different variables under one name; a session seeded from either reading would be wrong, so the workflow fails to load and the disagreement is named with both sources.

A check over the graph. check:activity-variables reports five things per workflow: an activity using a variable its contract omits, a contract declaring one it does not use, a read no activity in the graph writes and the workflow file does not own, a write nothing reads, and a read no path satisfies. The last splits in two. entry is a definite-assignment walk over the activity graph — a variable is available on entry to an activity only where every predecessor path makes it available. re-entry asks the same question of a return visit, and only of the reads that choose an exit: where no activity on the cycle writes the variable the choice tests, the second pass routes on the previous pass's value.

What an activity reads and writes is derived, not assumed, under the same name-match convention binding-provenance resolves a step's inputs with: the operation signatures a step binds (composed with their container contracts, as the step receives them), the tokens its protocol and artifact names interpolate, the gates, the routing conditions, the loop collections, the checkpoint effects and ids, and the set targets. Two roles read the same bag, so the orchestrator counts as a consumer too — the inputs of meta's workflow-engine operations and the reads of meta's own activities, since a worker writes some values for the engine rather than for a later activity. The declared contract is measured against all of that, so a stale declaration is a finding rather than a silent lie.

The declarations are redistributed. 408 of them move onto the activities that produce them, in #494. The workflow file keeps session facts and cross-activity policy. work-package goes from 140 declarations to 27, remediate-vuln from 79 to 20.

What this found, and what happened to it

Every finding was fixed. There is no ledger and no accepted debt.

65 unsupplied reads across a borrowed activity's real scope. remediate-vuln runs fourteen activities work-package wrote; checked in the workflow running them, they read thirteen session facts it had never declared. This is the first finding of #491, and this is where it becomes answerable. Fixed by declaring them.

Seven exits a return visit could not take. Two are the second finding of #491, in work-package's elicitation and research stages: a flag set once at classification, cleared by nothing, tested by a branch on a cycle. Fixed by having each stage clear its own flag.

130 declarations nothing used. 40 were declared and neither read nor written anywhere; 90 more were written and read by nothing. Of those 90, thirteen were the checker mistaking an optional operation input for a non-consumer — review_type is chosen at a checkpoint and read by the operation that posts the review, which derives it when unset. The rest were real: 52 checkpoint options writing a second record of a decision the session already keeps, 18 phase markers on paths with no branch, and 12 operation outputs declared as session variables when the technique layer was already carrying them. All removed in #494, with every route and every tested gate left intact.

Three guards that had stopped being able to answer their own question. check:variable-model, check:fragments and check:decision-order each asked whether a workflow declares a name by reading its workflow.yaml alone, which answers no for every declaration an activity contributes. scripts/workflow-declarations.ts assembles the same set the loader folds, including from an included activity's own file, and all three read it.

Delivery cost

Delivery rises 16,881 characters, 1.3%: get_activity +21,340 for the contracts, get_workflow −4,459 as the declarations leave and the dead ones go. The gate fixture is re-recorded against the corpus this branch pins, and states that split alongside the 8,444 characters of older corpus movement it had been carrying unrecorded.

Scope

The activity variables field and its schema; the contribution and the contradiction check at load; the derivation and graph walk in src/utils/activity-variables.ts; expressionPaths on the reference expression dialect, so the paths a gate reads come from the parser rather than a second reading of the grammar; the new guard; the three guards' declaration lookup; the documentation and the construct inventory.

Non-goals

No private scope: the session's variables stay one flat namespace at runtime, and what changes is that the namespace has declared contributors. Scoping would break the ordinary case where one activity reads what an earlier one wrote.

No change to how techniques declare their contracts. That boundary already worked and is already checked.

The re-entry check exempts a variable the workflow file declares, because policy for a run holds on a return visit by definition. That exemption is what separates a stage flag from a run mode, and it is why is_review_mode, skip_optional_activities, the analysis type and has_pr_surface are declared in their workflow files rather than on the activity that detects them.

Acceptance criteria

  • An activity states the variables it reads and writes, in its own file rather than in any workflow including it.
  • Including an activity contributes its declarations with no separate registration step, and two activities naming one variable mean one variable.
  • Two declarations of one name disagreeing on type or default fail the load, with the disagreement named.
  • A check reports unwritten reads, unread writes, and reads no path satisfies — at load time rather than at the step that improvises the value.
  • An activity included by a workflow that did not author it has its contract checked against the workflow running it.
  • The workflow file declares only what it owns, and the count it carries is stated before and after.

Verification

npm run typecheck; npm test — 1055 passing, 3 skipped; npm run check:all — 30 guards, all pass, activity-variables at zero; npm run check:delta — no new findings against the merge base; the delivery gate passes at 0%. Fifteen tests cover the contribution rule, the contradiction, both reachability cases, the policy exemption, the optional-consumer rule and the borrowed-activity seam end to end.

Investigation detail

The derivation boundary and the consumption channels it counts are in the module headers. The population behind the original report is the work-package planning folder for the inline-technique work: https://github.com/m2ux/workflow-server/tree/engineering/artifacts/planning/2026-08-15-handling-inline-techniques

🤖 Generated with Claude Code

m2ux and others added 5 commits August 23, 2026 11:27
An activity declares the session variables it reads and the ones it
writes. A read is a name — the activity needs the value and does not own
it. A write is a full declaration, because the writing activity is where
the variable is owned, and including that activity in a workflow's graph
contributes the declaration to that workflow's variable set. Being in the
graph is the registration: seeding, declared-type validation and the
get_workflow payload all read one merged set.

Two declarations of one name that disagree on type or default describe two
different variables under one name. A session seeded from either reading
would be wrong, so the workflow fails to load and the disagreement is
named with both sources.

src/utils/activity-variables.ts also derives what an activity actually
reads and writes, under the same name-match convention binding-provenance
resolves a step's inputs with, and walks the activity graph for reads no
path satisfies: one that some path reaches before any write, and one that
an exit tests on a cycle no activity in the cycle writes, where a return
visit routes on the previous pass's value. The expression dialect gains
expressionPaths, so the paths a gate reads come from the reference parser
rather than a second reading of the grammar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check:activity-variables reads every workflow's own graph — the activities
it holds and the ones it includes from elsewhere — and reports five things:
an activity using a variable its contract omits, a contract declaring one
it does not use, a read no activity in the graph writes and the workflow
file does not own, a write nothing reads, and a read no path satisfies.
An included activity is measured in the workflow running it, which is
where 65 unsupplied reads across remediate-vuln's borrowed activities and
seven unreachable exits came from.

The corpus carries 90 findings where a decision or a step records a flag
nothing consumes. They are triaged once per finding against four named
rationales, so a new one arrives untriaged and red. The verdicts, the file
shape and the reporting rules move to scripts/triage.ts, shared with
check:binding rather than copied beside it.

Three guards asked whether a workflow declares a name by reading its
workflow.yaml alone, which now answers no for every declaration an
activity contributes. scripts/workflow-declarations.ts assembles the same
set the loader folds, and check:variable-model, check:fragments and
check:decision-order read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The state-management model opens on ownership: the workflow file holds the
facts a session starts with and the policy spanning its activities, and
everything an activity produces is declared by that activity and
contributed on inclusion. The schema ontology table gains both halves of
the activity contract, and the corpus-debt section names the two ledgers
the shared triage protocol now serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pin moves to the corpus that declares each activity's variable
contract, and the walk baselines move with it. Two routing repairs show in
the walks: the elicitation checkpoint clears needs_elicitation, and
research runs one more step to close its own phase, taking work-package's
declared step count to 267 and its executed count to 150.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The delivery gate reads the fixture, and the fixture speaks for a corpus
three commits older than the one this branch adopts. Re-recorded against
workflows@5dd53325: delivery is 33,344 characters above the previous
recording, 24,900 of them this change and 8,444 corpus movement that was
never re-recorded. get_activity carries the rise, because an activity now
delivers the contract it reads and writes; get_workflow falls as the
declarations leave the workflow files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
m2ux and others added 13 commits August 23, 2026 13:08
An operation input marked optional is one the workflow need not supply, so
it is no read: the contract stays honest about what an activity requires.
Whether a value is consumed is a different question, and a value that
reaches an optional input is consumed by it — review_type is chosen at a
checkpoint and read by the operation that posts the review, which derives
it from the summary's rating when it is unset.

Thirteen writes read as consumed by nothing on that confusion, among them
a value whose own declaration named the operation it is bound to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two roles read the session's variables. A worker writes some values for the
orchestrator rather than for a later activity: a Progress row marked
cancelled where no validation suite could run, the outcomes a finished run
reports back. The orchestrator's operations are meta's workflow-engine
group and its own activities read the bag too, so both are consumers
wherever a workflow writes what they take.

A checkpoint reached more than once carries its instance in its id, so
scope-confirmed#{scope_round} is a read of the round counter beside it —
counted now, which is what makes the counter's own write live.
Every finding the check reported named a definition defect, and each is
fixed in the corpus, so the guard needs no verdict file and reports a plain
zero. The ledger goes, and with it the per-finding triage the new guard
carried.

That leaves check:binding as the one guard with a ledger, so its triage
protocol goes back inside it rather than living in a module with a single
caller. A shared home is worth its indirection at two users, not one.
The pin moves to the corpus with the dead state removed, and the walks and
the delivery fixture move with it. The walks lose the flags the checkpoints
no longer set; delivery falls 8,019 characters against the previous
recording, which is what 130 declarations and 77 writes come to.
The corpus sits at zero for this family, so nothing in the suite would
notice the check going quiet. A fixture writing a phase flag no gate tests
is what keeps a green corpus evidence of a clean corpus rather than of a
guard that stopped looking.
The all-workflows walk runs in graph mode and executes no steps, so a
broken sequence passes it — its own header says a workflow whose every
step binding was broken once walked it to completion. The six work-package
policy walks do execute steps, and between them they cover work-package
and the thirteen activities remediate-vuln borrows. Nothing covered the
other two workflows that lost a step.

plain-language and prism each walk far enough to run the activity that
changed, and each asserts the step that went is absent while the steps
around it still run. Reinstating the removed step fails the assertion,
which is what makes a pass mean something. Neither is a completion check:
prism's onward route needs a lens selection no workflow-agnostic policy
can invent, so whole-graph reachability stays with the all-workflows walk.
The smoke driver kept its own variable map, fed only from checkpoint
effects, and never passed variables_changed on the transition out. So the
run exercised half the variable model: a value the server writes itself
landed, and every value a worker derived was reported in prose and
dropped. A two-activity run ended with the issue type and the path
classification in the bag and the repository paths, the project type and
the planning folder absent — the writes most of the corpus declares.

The worker's report now ends with a fenced variables_changed block naming
what the turn settled, and the driver relays it on next_activity the way an
orchestrator does. Later turns supersede earlier ones, since a value
settled after a checkpoint resume is the current one. A missing block
contributes nothing rather than clearing what came before, and an
unparsable one is named in the log: a dropped value is indistinguishable
from a value the activity never produced, so it cannot pass quietly.

The driver's own routing reads that same map, so a transition gate now sees
what the worker settled rather than only what a checkpoint set.
resume_checkpoint described itself as returning the variable updates to
apply and returned a status and a note saying to apply whatever the
orchestrator had mentioned. The values were already in the bag — the server
applies the selected option's effect when it records the response — so the
tool had the answer and did not give it.

It now returns the resolved checkpoint, the option selected, and the
variables that option set, read from the response just recorded. A worker
resuming no longer infers its own state from an option id, which a live
agent run was observed doing twice and reported both times.
Three faults compounded into a run that claimed two activities and
executed one. A worker raised a gate the activity does not declare — the
sanctioned way to ask about something unanticipated — and the driver
answered it with the checkpoint id as the option id, because it looked the
options up in a definition an ad-hoc gate does not have. respond_checkpoint
refused that, the driver read no error, so the gate stayed active and every
following turn re-yielded it until the cap. Then the driver transitioned,
the server refused because a checkpoint was unresolved, the driver read no
error again, and it logged the next activity against a session still
sitting on the previous one. The transcript named an activity that never
ran, and the worker output relayed on that transition went nowhere.

An ad-hoc gate now answers with the first option the worker itself
offered: the gate exists because the worker could not proceed, and its
first option is the way forward it wrote. Both calls throw on refusal.
The turn cap says which state it stopped in rather than claiming to move on.

With this a two-activity run transitions properly and lands 17 worker
values in the bag, the repository paths, project type and planning folder
among them.
A worktree path is derived by walking up from the planning folder to the
repository enclosing it. The sandbox put the sessions in one directory and
the checkout in another, so which planning folder a worker chose decided
whether that walk found a repository at all: one run took the session's own
folder, landed on a directory with no git repository in it, and raised a
gate saying the worktree preconditions could not be resolved. It was right.

The session and the artifacts now share the checkout, and the orchestrator
names the planning folder at start_session rather than leaving a worker to
invent one — which is also how a real run establishes it. The folder
reaches the bag on the first transition, because start_session returns the
canonical path without seeding it, and the first activity reads it before
anything writes it.
The activity-variables guard compares a definition with itself. Nothing
compared a run with the definition, so every claim about whether the
contracts hold in practice came from someone reading a session file by
hand — which is how a run reporting two activities against a session that
never moved went unnoticed until it was read closely.

check-session-contract takes any session and reports two things: a
variable the run wrote while exiting an activity that declares no such
write, and a finished run that wrote and completed nothing. It works on a
smoke run's session, a walk's, or a real one, which is the only way to
learn whether the contracts hold with a person driving. An undeclared
write names both readings — the contract short of what the activity
produces, or the run producing what no definition sanctions — because the
session cannot tell them apart and a person can.

The smoke driver now calls it at the end and fails on findings, and adds
the one assertion only it can make: every value a worker reported is in
the bag, since the relay is the only path and a name lost between the two
is invisible from either side alone. Progress is asked for explicitly:
a session read mid-activity has no completions and is not in trouble.
The CLI returns the price and the model-turn count of every worker
dispatch, and the driver discarded both — so the cost of an agent run was
whatever someone estimated afterwards. Each turn now logs its own spend
and the run totals it.
The relay assertion failed its first run, on the run rather than on a
defect: it compared everything a worker had reported against the final bag,
and the last activity's output is still pending when a capped run stops.
Three names read as dropped that had simply never been sent.

Relayed now means carried by a transition the server accepted, and the
count says how many are still pending. The comparison is by value rather
than presence, because a name already in the bag from a seeded default
reads as present whatever the relay did — which is how eight of those
eleven values passed a check that should have been asking about all of
them.

The comparison moves into check-session-contract as relayGaps, where the
suite can exercise it: the only way to reach it before was an agent run
costing real money, and the first one spent that money discovering the
rule was wrong.
Sign up for free to 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.

Activity variable contracts: an activity should declare what it reads and writes

1 participant