Changelog
Architecture milestones, newest first. Each entry covers what changed at the contract and architecture level; implementation detail lives in the commit history.
2026-06 — Derived-node lifecycle: source deletions now propagate to the Store
FileWatcher previously handled two file events: create (new node) and modify (re-ingest → update). The third event — deletion — was silently ignored, leaving stale ghost nodes in the Store whenever a source file was removed.
What changed. FileWatcher now runs a deletion sweep on every poll tick. After scanning the current file set, it compares against the previously-ingested snapshot. For each file that has disappeared, it looks up Store nodes whose 来源路径 field matches the path and deletes them. The lookup is implemented via store.find_nodes_by_field_value(field_name, value), a new cross-supertag field query that does not require knowing the supertag name.
The invariant this establishes. Derived nodes — those ingested from an external source file — now have a complete and symmetric lifecycle: they are created when their source appears, updated when their source changes, and deleted when their source disappears. The Store reflects the external source's existence state without any manual reconciliation step.
Native nodes are structurally untouched. Nodes created inside runex (via create-node DSL effect, direct CLI, or reactive synthesis) carry no 来源路径 field and are therefore unreachable by the deletion sweep. The two-mode contract — derived nodes are a disposable cache, native nodes live in the Store permanently — is now enforced by lifecycle behaviour, not just convention.
Opt-out. FileWatcher(…, source_path_field=None) disables deletion propagation for adapters that do not write a path field (or where ghost retention is explicitly desired). The default remains "来源路径".
2026-05 — Dogfooding round: guard introspection, dry-run preview, blocking-kernel lint
Three usability gaps surfaced during real SCM scenario dogfood and closed.
Guard expression introspection. When dispatch() or the reactive bus rejects an action because its guard returned false, the result now carries a structured guard_trace list. Each entry names the failing sub-expression, the value it produced at runtime, and a note (e.g. "always-false literal" or the evaluated value of (field "client")). evaluate_guard_trace re-walks the guard AST after the fact — safe because guards are pure — and pinpoints the sub-expression responsible. Before: guard_rejected gave the action name and nothing else; an agent had to know engine internals to root-cause a silent rejection. After: the trace is machine-readable and surfaces directly in dispatch() Result.data and in the reactive_guard_rejected event payload.
Dry-run preview — ontology preview. runex ontology preview <action> <node-id> evaluates the full guard+effect+reactive cascade and returns the event list, but commits nothing. Implementation: Store._txn was changed from BEGIN/COMMIT to SAVEPOINT _sp / RELEASE (outer behavior identical, now nestable); facade.preview() wraps dispatch in a SAVEPOINT _preview / ROLLBACK TO SAVEPOINT _preview, capturing cascade events before the rollback. Result carries data.dry_run=true, data.ran, the full events list, and guard_trace on rejection. The failure mode that motivated this: agents ran ontology trace (which commits) as a preview step, consuming state transitions; when the user later said "execute", the engine returned IllegalTransitionError because the states were already consumed.
Blocking-kernel lint — register_kernel(..., blocking=True) + ontology check. The architectural decision: do not make the reactive bus async. Async dispatch introduces thread-local hazards (_depth counter is mutable instance state) and does not fix the real problem — non-idempotent writes (create-node) inside an action effect would race on concurrent bus wakeups. The root cause is action design, not bus threading. What shipped instead:
register_kernel(name, fn, *, blocking=False)— opt-in flag marks kernels that perform heavy I/O (subprocess, network, ASR).engine.blocking_kernels: set[str]— maintained alongsideengine.kernels.list_kernels()andmanifest()return[{"name": k, "blocking": bool}]dicts (previously plain name strings — agents can now read the flag before authoring an action).analyze_blocking_kernels()— static AST walk over every action'seffect_steps, flagging(call-kernel "NAME" ...)calls whereNAMEis inblocking_kernels. Returns structured{warnings, clean}.ontology checknow runs both conflict analysis and blocking-kernel lint, printingWARNINGlines with the Signal-Then-Work pointer.--strictexits 1 on any blocking-kernel warning (in addition to the existingerror-on-conflictgate).extension-authoring.mddocuments the Signal-Then-Work pattern: action effect writes a status field only (fast, atomic); external daemon detects pending status and runs the blocking kernel; a second reactive action reacts to the result field.
The thought_auto_transcribe action in runex-product is the canonical example of what the lint now catches. Its redesign into three-phase shape (detect → daemon runs ASR → analyze) is tracked as a product-side task.
2026-05 — ADR directory added for durable architecture decisions
The docs now include a dedicated ADR directory: adr/README.md.
The first accepted records capture decisions that had become important enough to stop re-arguing informally:
adr-0001-agent-native-ontology-runtime.mdadr-0002-capability-vs-channel-boundary.mdadr-0003-machine-first-contract.mdadr-0004-canonicalitem-bidirectional-boundary.mdadr-0005-reactive-semantics-over-imperative-orchestration.mdadr-0006-runtime-loadable-ontology-over-python-registered-logic.mdadr-0007-python-as-host-and-extension-interface.md
These ADRs complement, rather than replace:
architecture.mdfor the current structureroadmap.mdfor future directionwhitepaper.mdfor the long-form category thesis
2026-05 — White paper added for the technical vision
The repo now carries a long-form white paper: whitepaper.md.
It frames runex not as "just a protocol" or "just an app", but as an agent-native ontology runtime: a shared operating substrate for agents, human-facing shells, and external systems. The paper is problem-led rather than product-led: why current screen-centric apps, tool-calling agents, and workflow graphs leave a missing semantic runtime layer; why ontology, eventfulness, and runtime-malleable rules matter; and why channels, typed boundaries, and machine-first contracts are central to the category.
2026-05 — Repository-local Obsidian + NocoDB e2e workflow landed
The end-to-end validation loop is now repository-owned rather than an oral procedure.
What changed. The repo now carries a sanitized but structurally real Obsidian vault fixture, a disposable local NocoDB environment, a bootstrap that creates the 自媒体作品快照 / 作品快照 schema and seed rows, and one formal suite proving both source-read and writeback paths. A single script — scripts/run-local-e2e.sh — starts the service, bootstraps it, loads the generated env, and runs the suite.
Why this matters. The capability/channel architecture is no longer validated only by unit tests, stub-at-seam tests, or one-off production dogfood. The repository now contains a repeatable, safer-than-production business-loop check for:
Obsidian Thought -> ingest- reactive bilibili detection
Bookmarkcreation- NocoDB writeback through the real sink/source adapters
What is proven. OrbStack headless was verified as a working local Docker-compatible runtime for this environment, and the formal suite now passes against the local NocoDB service.
2026-05 — Extension authoring contract documented
The capability/channel architecture now has its Python-side authoring guide. extension-authoring.md documents the runtime contract for:
DataSourceSpecsource extensionsSinkSpecsink extensionsKERNELSextension modules
It makes the hot-discovery shape operational for extension authors: directory layout, discovery semantics, capability injection, spec-vs- configured-sink distinction, and the expected testing layers from unit tests through e2e ontology wiring.
2026-05 — Channel extension hot-discovery completed
The last open P1 from the capability/channel architecture landed: the registry is now hot-pluggable in both directions, not just for kernels.
What changed. At startup, runex now scans ~/.runex/extensions/sources/*.py and .../sinks/*.py in addition to .../kernels/*.py. A module-level SPEC: DataSourceSpec or SPEC: SinkSpec is merged into the lazy registry automatically, so a new channel can appear in manifest() and CLI registry lookups without editing the core module list.
Why the implementation moved. Discovery helpers were extracted to a top-level runex.extensions module. The registry can depend on this without importing the ontology package and re-entering facade startup, so the extension path stays cycle-safe.
What is proven. tests/test_extension_discovery.py now covers all three extension buckets: missing dirs are a silent no-op; kernel discovery still works; source/sink extensions appear in manifest().data.datasources / manifest().data.sinks once dropped into the extension dir.
2026-05 — Convergence determinism (4 gaps closed)
Four correctness gaps in the reactive bus were identified and closed behind regression tests. All are observable through the typed event log.
Gap 1 — Ordering enforced at dispatch, not at load. priority ASC, name ASC is now applied inside _on_commit at the point a matched group is formed, not only when actions are loaded. Reloading actions in a different order no longer changes dispatch outcome.
Gap 2 — error-on-conflict fails closed on opaque writes. An error-on-conflict action whose derived field target is not a literal (kernel result, variable — anything static analysis cannot resolve) is now blocked (opaque_blocked) rather than passed through. The guarantee it asserted cannot be proven, so refusal is correct.
Gap 3 — Cross-event same-field conflicts surfaced statically. Two actions on different triggers that write the same field never share a dispatch plan. analyze_latent_conflicts() / manifest().latent_conflicts / runex ontology check now run a whole-ontology static pass and classify findings as blocking (an error-on-conflict field is co-written elsewhere) or advisory. Catch it at design time; the append-only tx_log cannot un-write a prior cascade hop.
Gap 4 — Lint gate is real. ruff check now runs in the test suite (tests/test_lint_gate.py). Pre-existing violations were fixed. The datasource capability registry (adapters/registry.py) landed as part of this milestone: adapters self-describe via SPEC: DataSourceSpec, manifest().data.datasources exposes the registry as structured data.
2026-05 — Contract leap (Phases 0–5)
Rebuilt the L4 interface as a machine-first contract. Previously the facade returned heterogeneous shapes; CLI output was Rich prose; failure was sometimes a raised exception and sometimes a returned dict depending on which operation was called.
Result envelope. Every operation — load, dispatch, manifest, events, analyze_conflicts — now returns {ok, data, error, events, cursor}. ok:false carries a stable error.code (branch on this; never regex message). The CLI --json flag emits this envelope verbatim; exit code mirrors ok.
Closed Event taxonomy + actor provenance invariant. event.kind is drawn from a closed set, readable from manifest().data.event_kinds. Actor field carries one invariant: reactive:<action> ⟺ that action's effect committed; system:reactive ⟺ a decision event (guard rejected, blocked conflict, illegal transition). Nothing is swallowed.
manifest() as single introspection surface. One call returns the entire introspectable surface: supertags, machines, actions, kernels, datasources, event_kinds, and known latent_conflicts. An agent reads what is possible from this one structured call before acting — never from documentation or source.
Event stream with cursor. events(since, limit, kinds) is cross-process-safe and resumable. cursor is the scanned-window tail — it advances even when a kind filter matches nothing, so a follower on a rare kind never wedges. runex ontology events --follow is a safe cross-process tail.
Datasource registry. Adapters self-describe via SPEC: DataSourceSpec. manifest().data.datasources exposes the registry; DataSourceSpec.build(ctx) validates required params before construction with a structured error naming what is missing.
analyze_conflicts() pre-flight. Returns {field_conflicts, opaque_guarded_actions, blocking_count, clean}. runex ontology check --strict exits 1 on blocking_count > 0 — drop it in CI as a design gate.
Policy-A migration gate. upsert_supertag now enforces: additive schema changes always accepted at runtime; destructive changes refused when nodes exist; byte-identical re-apply is a true no-op (no event emitted, no churn on repeated load).
2026-05 — Data-driven ontology layer
The foundational architectural shift: state machines and actions became declarative data, not Python code.
Before. State machines were Python @register Action subclasses. Adding a new lifecycle required a code edit, restart, and deploy. Agents could not synthesize behavior at runtime. Cascade behavior was imperative call-site coupling.
After. A machine is a node tagged MachineDefinition; an action is a node tagged ActionDefinition. Both are written in a small Scheme-like DSL (s-expressions) stored as longtext fields. A reactive bus subscribes to store mutations and auto-dispatches matching actions.
Shipped in 8 phases: DSL kernel (parser + evaluator) → internal supertags + store_io → Engine + Kernel registry → Bookmark machine as data (first dogfood) → Thought machine + cross-node writes → retire old Python framework → ReactiveBus + Store.on_commit → wiki-link extraction as a reactive action → Ontology facade + CLI + agent guide → pipeline + reactive ingest e2e.
Outcome. A single upsert_node_from_item() call produces a fully-processed graph — tagged, fielded, wiki-linked, triaged, zettelized — with zero orchestration code in the pipeline. Behavior changes by editing .scm files; no Python touched, no restart required.
Migration path. from runex.ontology import register, run_by_name is gone; use Engine.dispatch or Ontology.dispatch. Machines and wiki extraction are now .scm in config/ontology/. The old Python framework classes are deleted. store.resolve_wiki_links() and store.sync_all_wiki_links() are gone; wiki extraction is reactive and automatic once wiki_links.scm is loaded.