Commit 2ebec19
authored
feat(flow): openregister.map, own the Twig engine, and the sync-decomposition design (#2313)
* feat(flow): add openregister.map so a flow can transform data mid-walk
The engine had no node that transformed data, so any flow needing to reshape a
payload had to route it out to an endpoint rule and back. That is why
integrations ended up expressed as endpoint chains — not because endpoints
fitted better, but because the flow engine could not map.
The node maps PER ITEM, like FilterNode, so one authored mapping reshapes a
collection without the author drawing a loop. It resolves a mapping by numeric
id, uuid, slug or reference: a flow definition is portable between instances
where the numeric id differs, so an exported flow that resolved only by id would
break on import while looking correct.
An unresolvable mapping FAILS the step. Returning the items unchanged would
record a completed step that transformed nothing, and the error would then
surface at some later step reading the un-mapped shape, far from its cause.
Verified: live in /api/flow/node-catalog (18 nodes), and 8 unit tests. The
fail-closed test was confirmed by mutation — patching the node to return items
on an unresolved mapping makes exactly that test fail, and nothing else.
Also corrects the proposal against the RUNNING catalogue rather than a reading
of the code. Two claims in it were wrong: OpenConnector does contribute nodes
(source-call, synchronization-run), so `synchronization` already had one; and
the rule dispatcher has 23 types, not 18. The remaining gap is still thirteen,
but its membership is different from what was written.
* feat(mapping): own the Twig engine; let apps contribute what only they can
Moves mapping's Twig surface into OpenRegister and gives other apps a way to
add the functions OpenRegister cannot provide for itself.
Ported from OpenConnector's copy, so mappings authored there keep evaluating:
createSlug, json_decode, b64enc/b64dec. createSlug is copied byte-for-byte on
purpose — mappings persist slugs as object identifiers, so changing the
transformation would silently orphan previously-written objects.
`json_decode` AND `jsonDecode` are both registered. OpenConnector's templates
call the first, OpenRegister's runtime spelled it the second. A mapping is
authored data, so renaming the function it calls breaks it at evaluation time
with nothing at author time to warn you.
RegisterMappingFunctionsEvent is the contribution point, same shape as
RegisterFlowNodesEvent. registerFunction() adds the function AND allowlists it
in one call, because the environment is sandboxed: a contributed function that
is not allowlisted fails as "unknown function" deep inside a mapping, nowhere
near where it was registered.
Collection is best-effort — a missing app or a throwing listener must not stop
mappings evaluating, since the engine has to work on an instance where nothing
else is installed.
Verified live through the sandboxed engine:
createSlug("Hello World App") -> "hello-world-app"
json_decode('{"a":1}')["a"] -> 1
b64enc("hi") -> "aGk="
and the negative control still holds — system() and an unlisted filter are both
refused, so the sandbox was widened deliberately, not disabled.
* docs(flow): design for decomposing the sync monolith, and first-class iteration
openconnector.synchronization-run is the last monolith in the catalogue: one
node that runs an entire synchronisation. The run history records it as a single
step, so which page failed and which record was skipped are not queryable — the
exact ambiguity the step table was built to remove.
Four capabilities are trapped inside it with no node equivalent: pagination,
hash change-detection, synchronisation-contract resolution, and the contracted
write. The contract one is load-bearing: without it a flow-built sync is not
idempotent, so a second run duplicates rather than updates. That is why the
monolith still has to be used whole, and why it is deprecated rather than
deleted by this change — deleting it first would strand every existing sync.
The design decision is how to model iteration. Three options, written up in
design.md:
A. Cycle in the graph — what the engine does today. Executes correctly, rejected
as the AUTHORING model: a back-edge looks identical to a forward edge, so the
most important fact about the graph is carried by edge direction; loop
membership is inferred rather than declared; the bound is a whole-run ceiling
shared by every loop; and non-convergence is diagnosed after the side effects.
B. Sub-flow per iteration — rejected. Forces a one-loop-per-flow split unrelated
to how the author thinks about the work, and fragments run history across
runs, reintroducing the ambiguity we just removed.
C. A declared loop REGION — chosen. The loop is a node that owns its body, so
membership is data. The builder can then draw it as a container rather than
an edge that happens to point backwards, bounds are per-loop and authored,
non-convergence becomes a save-time validation error, and each body step
records its iteration index so "page 7 failed" is a query.
Also records why publiccode is the right first example rather than a toy: it
needs a PAT for rate limits, and the fleet's credential shape means the flow
must never hold one. doriath stores the secret zero-knowledge; OpenRegister's
`github` credential is a HOST-LOCKED PROXY whose resolveInjectable() returns
null — a routing signal meaning "use request()", not a denial. So the harvest
needs openregister.broker-call, which asks the broker to make the call
server-side. Integrating correctly with doriath means never fetching the token.
openregister.loop is renamed in the palette to "Batch items" (it batches, it
does not loop) but KEEPS its id — stored flow definitions reference it, and
renaming an id breaks authored data. Same reasoning that kept both json_decode
and jsonDecode when mapping consolidated.
* docs(flow): correct two design calls — rename the id, and keep credentials on the Source
Both corrections came from review and both reverse what I had written.
**openregister.loop gets a NEW ID, not just a new label.** I had kept the id on
the grounds that stored flow definitions reference it. Wrong trade: a node whose
id says `loop` and whose behaviour is `batch`, sitting next to a real `iterate`,
is a trap that re-arms every time someone new reads the catalogue. Unlike a Twig
function name — which a person typed into a mapping template we cannot safely
rewrite — a node id is a reference the system writes and can rewrite. So it
becomes `openregister.batch`, stored flows are MIGRATED, and the old id stays a
resolvable alias for one release so a flow exported before and imported after
still resolves. The alias logs when used, so the tail of un-migrated definitions
is visible rather than assumed empty.
**No openregister.broker-call.** I had proposed a second node for calls using a
brokered credential. That exposes an implementation detail as a modelling
choice: from the author's chair both nodes call a configured source, and picking
correctly requires knowing which credential SHAPE the source carries — which is
exactly what the broker exists to hide. Choosing wrong yields "resolveInjectable
returned null", which reads as a permission problem and is not one.
One node instead. A Source may reference a doriath-held credential and resolves
it by shape: injectable is attached to the request as now, host-locked is handed
to OpenRegister's broker to perform server-side. The author configures a
credential and calls the source. The brokering stays under the waterline, and
the property that matters is unchanged — the token is never handed to the flow.
* test(mapping): assert Twig functions by NAME, not by count
testGetFunctionsReturnsArray asserted exactly 2 functions and broke the moment
mapping consolidated (7 now). The count told us a number had changed — not
whether anything was MISSING, which is the only question that matters here: a
stored mapping calls these by name, so losing one breaks authored data at
evaluation time with nothing at author time to warn you.
Now asserts each expected name is present, and adds a case pinning json_decode
as reachable BOTH ways: OpenConnector's templates call `json_decode(x)`,
OpenRegister's use `x|json_decode`. Consolidation kept both forms rather than
picking one, and nothing was testing that.
12 tests, 67 assertions.
* style: clear phpcs/phpmd on the mapping-consolidation surface
All introduced by this branch; none pre-existing.
- phpcs spacing across the new event, MapNode and MappingService docblocks.
- MappingRuntime: TooManyPublicMethods suppressed with the reason — a Twig
runtime's public methods ARE the vocabulary templates may call, so the count
is the size of that vocabulary. Splitting it would mean two runtimes and a
rule about which functions live where, a distinction template authors would
have to know and could not see.
- json_decode: CamelCaseMethodName suppressed. The snake_case name is not a slip
and cannot be corrected — it is the identifier stored templates already
contain.
- MapNode: StaticAccess (FlowItems::item is the item constructor every node
uses) and UnusedFormalParameter ($context is part of the IFlowNode contract;
a mapping transforms the item, not the run), matching ExplodeNode's reasoning.
Left alone: GenericStoreService's inline-IF, which this branch does not touch.
84 tests / 169 assertions green across Twig and MapNode.
* feat(flow): openregister.iterate — a declared loop region
The engine could already loop: it is a Petri net, so an edge drawn backwards is
a cycle. That is an execution capability, not an authoring one. A back-edge
looks identical to a forward edge, so the most important fact about a graph —
that a region repeats — was carried by edge DIRECTION. Loop membership was
inferred by tracing rather than declared, the bound was a whole-run ceiling
shared between every loop, and non-convergence was diagnosed only after the
side effects had happened.
Here the loop OWNS its body, so membership is data: a builder can draw the
region as a container, the bound belongs to the loop that overran so the error
names it, and each body step runs with its iteration index in scope.
Termination is deliberately ONE rule: stop when the source returns no items.
Pagination falls out of that without a second concept — a page past the end is
empty — and `context['iteration']` carries the index so the source can ask for
the right page.
Two behaviours worth stating because getting either wrong is silent:
- Items ACCUMULATE across passes. Returning only the final batch would discard
every earlier page while still reporting success.
- A non-converging loop FAILS by default. `onLimit: stop` is available but must
be chosen; the default cannot be "quietly keep going and then quietly stop".
Validation refuses a sourceless loop, an empty body, a typeless body step and a
non-positive limit — at SAVE time, because a loop that cannot terminate is the
one authoring mistake whose cost is paid in side effects.
The dispatcher is resolved from the container at execute time, not injected:
this node lives in the registry the dispatcher reads from, so constructor
injection would close a cycle mid-population.
11 tests, and both mutations caught: returning only the last batch fails 2
tests, never failing on overrun fails 1.
* feat(flow): rename loop->batch with migration; split iterate validation
**openregister.loop becomes openregister.batch.** It never looped — it splits
items into fixed-size batches — and next to the new openregister.iterate the old
name was a trap that re-armed for every new reader.
The id changes, not just the label. A node id is a reference the SYSTEM writes
into a flow definition, unlike an identifier a person typed into a template, so
it can be corrected and the data rewritten. Version1Date20260804000000 rewrites
the quoted id in the nodes and edges of every stored flow and reports the count.
The registry keeps a LOGGED alias for one release, covering the one case the
migration cannot reach — a flow exported before the rename, imported after — so
the size of that tail is observable rather than assumed to be zero.
The migration matches the quoted id as text rather than walking the structure:
an exact quoted match cannot hit a prefix, and decode/re-encode would risk
reordering keys in definitions it has no business touching.
Also: IterateNode::validateConfig split into assertSource/assertBody/
assertBounds (was CC 11 / NPath 216), and the flow-iteration spec written — the
@SPEC anchors on the new code pointed at a file that did not exist yet, which
PHPCS would have accepted and gate-46 would not.
Merged development in, which brought AppHost. Fixed the inline-IF phpcs failure
in GenericStoreService while there.
NOT fixed, and pre-existing: tests/Unit/AppHost has 3 errors + 7 failures on
development. Verified by reverting my GenericStoreService change and re-running
— identical counts, so none of it is mine. Left for whoever owns that work
rather than absorbed silently into this PR.1 parent b9024c2 commit 2ebec19
19 files changed
Lines changed: 2097 additions & 26 deletions
File tree
- appinfo
- lib
- AppHost/Service
- Listener
- Migration
- Service
- Flow
- Nodes
- Twig
- openspec/changes
- flow-parity-mapping-and-webhooks
- flow-sync-decomposition
- specs/flow-iteration
- tests/Unit
- Service/Flow/Nodes
- Twig
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | | - | |
| 43 | + | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
292 | 292 | | |
293 | 293 | | |
294 | 294 | | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
295 | 303 | | |
296 | 304 | | |
297 | | - | |
| 305 | + | |
298 | 306 | | |
299 | 307 | | |
300 | 308 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
| 34 | + | |
33 | 35 | | |
34 | 36 | | |
35 | 37 | | |
| |||
68 | 70 | | |
69 | 71 | | |
70 | 72 | | |
| 73 | + | |
| 74 | + | |
71 | 75 | | |
72 | 76 | | |
73 | 77 | | |
| |||
82 | 86 | | |
83 | 87 | | |
84 | 88 | | |
85 | | - | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
86 | 92 | | |
87 | 93 | | |
88 | 94 | | |
| |||
115 | 121 | | |
116 | 122 | | |
117 | 123 | | |
| 124 | + | |
| 125 | + | |
118 | 126 | | |
119 | 127 | | |
120 | 128 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
52 | 74 | | |
53 | 75 | | |
54 | 76 | | |
| |||
213 | 235 | | |
214 | 236 | | |
215 | 237 | | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
216 | 253 | | |
217 | 254 | | |
218 | 255 | | |
| |||
0 commit comments