Skip to content

intent: a status a processes: flow writes is refused to a direct create/update (#7339) - #7357

Open
delchev wants to merge 1 commit into
masterfrom
issue-7339-workflow-owned-status
Open

intent: a status a processes: flow writes is refused to a direct create/update (#7339)#7357
delchev wants to merge 1 commit into
masterfrom
issue-7339-workflow-owned-status

Conversation

@delchev

@delchev delchev commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

The cause

An entity whose function: EntityStatus relation is moved by a processes: step had that column generated as an ordinary writable property on all three REST surfaces. A plain

PUT /services/java/vacations/gen/vacations/api/vacationrequest/VacationRequestController/7
{ ..., "Status": 3 }          # 3 = APPROVED
-> 200

moved the document into APPROVED with the flow bypassed end to end - no capacity check, no manager task, nothing charged - and the record read approved while the accounts knew nothing about it. immutableWhen: cannot close it (it locks the way OUT of a final status; this is the way IN, from a DRAFT that is mutable by definition) and a transitions[] button is an additional guarded endpoint beside the plain PUT.

The change

The status a flow writes is derived state owned by the flow, the same class of column as a roll-up target.

  • EdmIntentGenerator.putWorkflowStatus emits workflowStatusProperty (the FK) and workflowStatusInitial (the relation's init:) on the entity map - derived from the process that writes it (writesStatus: a setRelationField step of a process this entity triggers), not declared, since a model that states a flow over the status has already said who owns it. Scalars, so they reach the .edm twin like immutableStatusValues.
  • The three controller templates (EntityController, EntityMyController, EntityPartnerController) refuse a create or update that sets or changes it: 409 'Status' changes through the workflow, not a direct edit.
  • The flow's own writers lose nothing: a setRelationField step and a transitions[] endpoint reach the repository through the targeted updateProperty / updateProperties primitives, never through a controller.

Two deliberate non-refusals:

  • An absent value is not a change - it is taken from the stored row, which is also what stops a partial payload from erasing the status (a whole-record update wrote the null back before).
  • A create carrying exactly the declared init: starts the record where the model says it starts; any other value, and any value at all where no init: is declared, is refused - a record cannot be created mid-flow.

What I deliberately left out, and why

The issue asks for the refusal when a processes: flow or transitions: writes the status. Only the processes: half is implemented. A transitions[] button is a user action over the status - the declared way a person moves it by hand - and the construct that guards every OTHER hand write is lifecycle:, enforced in the generated repository precisely because writers other than the button exist. Claiming the column on the strength of a button would make an unmodeled move reachable from nowhere and the state machine's own refusal observable from nowhere (IntentEmissionCoverageIT's lifecycle assertions are exactly that: an illegal edge pushed through the plain REST surface, "which no transition guard covers"). That is a different construct removed rather than this one delivered. Happy to extend it if you want the broader reading - it is one predicate in writesStatus, plus rerouting the coverage fixture's status writes through transition endpoints.

Verification

  • EdmIntentGeneratorTest - the process-driven status emits both scalars, a same-nomenclature entity with no flow emits neither, a transitions-only entity stays writable (68 tests green).
  • IntentWorkflowStatusIT (new, HTTP-only, ~35 s) - generates + publishes a real intent and asserts all four answers end to end: the DRAFT -> APPROVED jump is 409 with the message, a create naming APPROVED is 409, an ordinary edit carrying the status back unchanged is 200, an edit that omits it does not erase it, and the flow's own setRelationField still moves the record to APPROVED on task completion.
  • IntentEmissionCoverageIT (compiles + runs the generated client Java), IntentEngineIT (80), IntentCheckGateRefusalIT, ModelGenerationIT - green.
  • components/engine/engine-intent + components/ide/ide-template unit suites green; mvn formatter:validate green with the cache wiped; javadoc release check green.

Not run locally: the rest of the IT suite (CI's job).

Docs: dirigible-io/dirigible-io.github.io#244 (help site) and IntentFile/intent-specification#75 (spec proposal).

Fixes #7339

🤖 Generated with Claude Code

…te/update (#7339)

An entity whose `function: EntityStatus` relation is moved by a `processes:`
step had that column generated as an ordinary writable property on all three
REST surfaces, so a plain `PUT {"Status": 3}` moved the document straight into
APPROVED with the flow bypassed end to end: no check ran, no task was ever
raised, nothing the flow charges was charged - and the record read approved
while the accounts knew nothing about it.

`immutableWhen:` cannot close it (it locks the way OUT of a final status, while
this is the way IN, from a DRAFT that is mutable by definition), and a
`transitions[]` button is an ADDITIONAL guarded endpoint beside the plain PUT.

The status is derived state owned by the flow, the same class of column as a
roll-up target, so the EDM generator marks it - `workflowStatusProperty` plus
`workflowStatusInitial` (the relation's `init:`), derived from the process that
writes it rather than declared - and the three controller templates refuse a
create or update that sets or changes it with 409 naming the column. The flow's
own writers lose nothing: a `setRelationField` step and a `transitions[]`
endpoint reach the repository through the targeted updateProperty /
updateProperties primitives, never through a controller.

Two deliberate non-refusals: an absent value is not a change - it is taken from
the stored row, which is also what stops a partial payload from erasing the
status - and a create carrying exactly the declared `init:` starts the record
where the model says it starts.

A `transitions:`-only entity keeps its writable column on purpose: the button is
a hand move over a status a person may also hold otherwise, and the construct
guarding every other hand write is `lifecycle:`, enforced in the repository
precisely because writers other than the button exist. Claiming the column there
would make an unmodeled move reachable from nowhere and the state machine's
refusal observable from nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant