ADR-005 accepted: resolve all open consequences, update cascading docs

Resolve the three open consequences from ADR-005 (Event Log as Single
Source of Truth) and transition from Proposed to Accepted:

1. Event log IS the call protocol event stream — not a separate type,
   but an EventLogProjection interface (append/getStatus/getResult/
   getEvents) over CallEventMapValue[] with an append-only contract.

2. Event log persists across template re-renders — projections recompute
   against the new DAG; orphaned events stay in log for audit but don't
   affect active projections.

3. Edges get dataFlow: boolean attribute on TemplateEdgeAttrs — inferred
   (not manual) by GraphologyHostConfig from template expressions.
   typeCompat() only runs on dataFlow: true edges. Inference rules are
   precisely specified for Conditional.test, Map.over, and Operation.input.

Also resolve OQ-05 (structural containers stay transparent; aggregate
status is a projection from children) and OQ-10 (running node failure
is a FailurePolicy configuration, default continues-running).

Cascading updates to:
- reactive-execution.md: add hybrid status model (event-log-driven vs
  projection-driven vs signal-mutation), EventLogProjection interface,
  result projection respecting retries, FailurePolicy type
- host-configs.md: ReactiveContext now includes resultProjection and
  computed results; resolved Q1/Q3/Q4
- schema.md: dataFlow attribute on TemplateEdgeAttrs with inference
  rules and type checking implications
- workflow-templates.md: edge creation rules with dataFlow, result
  projection in Conditional/Map, resolved Q1/Q4
- open-questions.md: all ADR-005 questions marked resolved, updated
  summary table and cross-cutting themes, removed duplicate OQ-07

7 files changed, 464 insertions, 139 deletions
This commit is contained in:
2026-05-21 07:44:28 +00:00
parent 2c1b2d1a15
commit c76be7f689
7 changed files with 463 additions and 138 deletions

View File

@@ -2,7 +2,7 @@
## Status
Proposed
Accepted
## Context
@@ -139,11 +139,111 @@ This resolves OQ-01: incompatible edges (type mismatches) only exist on state-tr
- **Event replay must be idempotent.** Processing the same event twice must produce the same projected state. This is already a property of the call protocol events (`updateFromEvent` is documented as idempotent in call-graph.md).
- **The result projection needs a clear interface.** `getResult(nodeId)` must be defined — what it returns, when it's available, and how it interacts with `Conditional.test` and `Map.over` closures that may reference results from nodes that haven't completed yet.
### Open
### Resolved: Event log is the call protocol event stream
- **Should the event log be its own exported type, or is it the call protocol event stream by another name?** The call protocol already defines the events. The event log might just be `CallEventMapValue[]` with an append-only contract and projection functions.
- **How does the event log interact with the ujsx template lifecycle?** When a template is rendered to a reactive root, the log starts empty and populates as events arrive. But if the template is re-rendered (when the reconciler supports it), what happens to the log? Is it reset, or does it persist across re-renders?
- **Should temporal-only edges be explicitly marked?** Currently `sequential` edges are always temporal ordering. Data flow is implicitly expressed by `Conditional.test` and `Map.over` reading from the result projection. Should edges carry an attribute that explicitly marks them as notification vs. state transfer? This would make type checking more precise (only check types on state-transfer edges).
The event log is NOT a separate type. It IS the call protocol event stream with an **append-only contract** and **projection functions**. The call protocol events (`CallEventMapValue[]`) already carry everything needed:
- `requestId` — identifies which invocation
- `operationId` — identifies which operation
- `input`/`output` — the payload data (for state transfer edges)
- `parentRequestId` — the causation link
- `timestamp` — when it happened
What flowgraph provides is not a new event type, but a **consumption contract**:
```typescript
interface EventLogProjection {
/** Append an event. Events are processed idempotently. */
append(event: CallEventMapValue): void;
/** Current status of a node, derived from the most recent event. */
getStatus(nodeId: string): NodeStatus;
/** Result of a completed node, derived from call.responded events. */
getResult(nodeId: string): CallResult | undefined;
/** All events for a node, in order. */
getEvents(nodeId: string): CallEventMapValue[];
}
```
The `EventLogProjection` interface makes the append-only discipline explicit and provides typed access to projections. Implementations wrap `CallEventMapValue[]` and derive state on demand (or with memoization). This avoids creating a parallel type system — the event types, their structure, and their semantics remain in `@alkdev/operations/src/call.ts`.
### Resolved: Event log persists across re-renders; projections recompute
When a template is re-rendered (when the ujsx reconciler supports it), the event log persists. Events are append-only facts — they record what happened, and what happened doesn't change when the template structure changes.
Projections are recomputed by scanning the log against the new DAG:
1. Events for nodes still in the DAG map naturally to their projections.
2. Events for nodes removed from the DAG become **orphaned events** — they remain in the log (for audit/history) but don't affect active projections.
3. New nodes added to the DAG have no events yet — their status is `idle` and their result is `undefined`.
This means re-rendering doesn't lose history. The event log is the durable record; projections are ephemeral views that can always be reconstructed.
For v1 (before the reconciler exists), the event log starts at template mount and is disposed when the `WorkflowReactiveRoot` is disposed. The re-render scenario is an architectural commitment for when the reconciler arrives, not something to implement now.
#### Orphaned events specification
When a template is re-rendered and nodes are removed from the DAG, their events become orphaned. The projection layer handles this as follows:
1. **The `EventLogProjection` receives the current DAG structure** (the set of active node keys) alongside the event log. Methods like `getStatus(nodeId)` first check whether `nodeId` is in the active DAG. If not, the node is orphaned.
2. **Orphaned nodes return `undefined` from `getResult()`**. A downstream node referencing an orphaned predecessor via `Conditional.test` or `Map.over` will see `undefined`, causing the test to evaluate as if the predecessor didn't complete. This is the correct behavior — a removed node can't provide data.
3. **Orphaned events remain in the log** for audit and history. `getEvents(nodeId)` on an orphaned node returns its events (if any). The overall event log is still queryable for debugging.
4. **The `nodeKeyToRequestId` map is rebuilt on re-render**. New nodes get fresh `requestId` values. Old mappings are discarded, along with their associated signal subscriptions (the `WorkflowReactiveRoot.dispose()` call before re-render handles this).
### Resolved: Edges are marked with `dataFlow` attribute
Template edges get a `dataFlow: boolean` attribute that distinguishes temporal edges from state-transfer edges:
| `dataFlow` value | Meaning | Type checking needed? |
|:---|:---|:---|
| `false` (default) | Temporal ordering only — downstream starts after upstream completes but doesn't read upstream's output | No — no data flows between nodes |
| `true` | State transfer — downstream reads upstream's output via `Conditional.test` or `Map.over` | Yes — `typeCompat()` checks output→input compatibility |
This attribute is **inferred, not manual**. The `GraphologyHostConfig` detects `dataFlow` from template expressions during rendering:
- A `Sequential` edge where the downstream node references `results["upstreamNode"]` in `Conditional.test`, `Map.over`, or `Operation.input` gets `dataFlow: true`
- A `Sequential` edge where no such reference exists gets `dataFlow: false` (the default)
- A `Conditional` edge always gets `dataFlow: true` (the condition always reads a predecessor's result)
- `Parallel` edges don't exist (parallel children have no inter-sibling edges)
#### dataFlow inference specification
The inference algorithm operates at **template AST level** during `GraphologyHostConfig.createInstance` / `appendChild`, not at runtime. It inspects template component props to detect references to predecessor results:
**Detectable references** (set `dataFlow: true` on the edge from the referenced node to the referencing node):
| Expression | Detection method |
|:---|:---|
| `Conditional.test = (results) => results["X"]` | Static analysis of the function body for `results[...]` property accesses |
| `Conditional.test = "X"` (string form) | String comparison — the referenced operation name |
| `Map.over = (results) => results["X"].output.items` | Static analysis of the function body for `results[...]` property accesses |
| `Map.over = itemsSignal` (signal form) | No `dataFlow: true` — the array comes from a signal, not a predecessor result |
| `Operation.input = (results) => results["X"].output` | Static analysis of the function body for `results[...]` property accesses |
| `Operation.input = staticValue` | No `dataFlow: true` — the input doesn't depend on a predecessor result |
**Inference rules**:
1. **Direct predecessor edges only**: `dataFlow: true` is set only on edges that exist in the DAG. In a `Sequential` chain A → B → C, if C references `results["A"]`, the edge B → C gets `dataFlow: true` (since A is a predecessor of C via the chain), but no new edge A → C is created. Data flows transitively through the chain — B must complete before C starts, and C reads A's result from the result projection.
2. **`Map` component edges**: A `Map` component's predecessor-to-first-mapped-child edge gets `dataFlow: true` if `Map.over` references a predecessor result. Each mapped child's edge from the `Map`'s predecessor gets `dataFlow: true` because the array data comes from a predecessor's output.
3. **Ambiguous references**: If `Operation.input` is a function that cannot be statically analyzed (e.g., `(results) => computeInput(results)` where `computeInput` is a closure), the inference defaults to `dataFlow: false`. Template authors can manually annotate with `dataFlow: true` as an override, though this should be rare.
4. **Function body analysis**: JavaScript function introspection is unreliable (minification, closures). Inference operates on the **AST** of the ujsx template during rendering, not on the runtime function body. This means that `Conditional.test` functions passed as closures from external code (not inline in the template) cannot have their references detected. For these cases, the string form (`Conditional.test = "operationName"`) should be used to ensure detectability.
The `dataFlow` attribute propagates to the `TemplateEdgeAttrs` schema:
```typescript
const TemplateEdgeAttrs = Type.Object({
edgeType: Type.Union([Type.Literal("sequential"), Type.Literal("conditional")]),
condition: Type.Optional(Type.Unknown()),
dataFlow: Type.Optional(Type.Boolean({ default: false })),
});
```
This resolves OQ-01 and OQ-02 precisely: `typeCompat()` only runs on edges where `dataFlow: true`. Temporal-only edges bypass type checking entirely.
## References