lifecycle — one object for every change to the systemchange_intent
All system changes converge into a single schema in ~/.local/state/metnos/change_intents.sqlite:
id UUID
fingerprint sha256[:32] # deterministic for cross-source dedup
state PROPOSED|ACCEPTED|APPLIED|OBSERVED|FINALIZED|
STAGED|REJECTED|FAILED|ROLLED_BACK
origin_family telos|introvertiva|synt|user (multi_tool/canonical: historical rows only)
origin_module scamper|dedupe|request_new_executor|L1|L2|feedback|...
intent_kind create_executor|extend_executor|dedupe_executors|
materialize_pipeline|cache_pattern|reject_pattern
intent_target executor name or pattern
intent_summary 1 user-facing sentence
intent_body kind-specific dict (arg_name, tools_sequence,...)
score 0–1 cross-source normalized
confidence 0–1
convergence # of sources proposing equivalent things
decision_* user accept/reject/stage log
applied_effect applied diff + rollback_blob path
observed_metrics grace-period metrics
The fingerprint does NOT include origin_family:
this way two different sources proposing equivalent things (e.g.
telos:scamper + synt on the same executor) coalesce into
a single record, with convergence bumped.
+-----------+ | PROPOSED | ← some source generated it +-----------+ / | \ / | \ (user) | (user decides later) / | \ v v v +----------+ +--------+ +---------+ | ACCEPTED | | STAGED | |REJECTED | +----------+ +--------+ +---------+ | (applier daemon) v +----------+ +----------+ | APPLIED | ----> | FAILED | (retry possible) +----------+ +----------+ | (observer daemon, default 7d grace period) v +----------+ +-------------+ | OBSERVED | ----> | ROLLED_BACK | (physical, per kind) +----------+ +-------------+ | v +-----------+ | FINALIZED | ← consolidated, hidden from default views +-----------+
From ANY state you can transition to ROLLED_BACK (human-audit
escape hatch). From REJECTED you can re-propose
(transition back to PROPOSED).
Each source has an adapter in runtime/change_intent_adapters/
that projects its records into ChangeIntent, with no side
effects. Since 2026-07-02 (ADR 0180, the layer rule) the active adapters
are four:
| Source | Adapter | Output kind |
|---|---|---|
| telos (10 lenses, cluster heads) | telos.py |
create_executor (new_valid) / extend_executor (parametric) / materialize_pipeline (existing_pipeline) |
| introvertiva (dedupe only) | introvertiva.py |
dedupe_executors (the historical generalize/specialize shapes stay readable, their generators are retired) |
| synt (request_new_executor) | synt.py |
create_executor (imported as FINALIZED if already installed) |
| user turn_feedback | user_feedback.py |
reject_pattern (only if ≥ 2 rejections) |
The telos adapter projects cluster heads
(recompose_clusters) rather than raw rows: roughly 27 intents
instead of 500, score = cluster_score (best alignment + a
convergence bonus across distinct lenses), and ONLY the actionable
name_status values — noise (redundant proposals, invalid names)
never reaches the view.
Retired adapters (2026-07-02, «a mechanism that serves no
purpose serves none»): canonical.py (the
canonical_query_log store was written by the legacy planner, now
gated — its role is absorbed by the L0 fastpath) and multi_tool.py
(the multi_tool_paths store no longer has a writer — real chains
are learned by the L1 autopath from actual turns). The modules live on in git
history; the leftover rows were rejected with an explicit reason.
Score normalization per family:
cluster_score (alignment + convergence), already 0–1uses via log-saturating mid=20
(e.g. uses=20 → score 0.50, uses=100 → 0.83)n_seen*0.05 + last_uses*0.005 (historical formula)installed=0.8, abandoned=0.3,
rejected*=0.05min(0.9, 0.3 + 0.15*n_rejections)| Daemon | Trigger | What it does |
|---|---|---|
change_intent_materialize |
daily@01:00 | Concatenates the live adapters → upsert with fingerprint dedup → bump convergence. |
change_applier |
every_10m | Reads ACCEPTED, applies physically per kind. Cap 20/fire. |
change_observer |
daily@03:15 | Reads APPLIED+OBSERVED, computes metrics, transitions to FINALIZED or ROLLED_BACK. |
Applier handler per kind:
synth_request
pipeline (~150s wall). Idempotent: short-circuits if already in catalog.change_applier_extend.py.
Appends [args.properties.<arg>] section at the end of the
TOML manifest, backup pre-modification in
~/.local/share/metnos/rollback_blobs/<sha8>-<name>.toml,
re-sign via sign.sign_executor.executor_aliases.json + deprecate the duplicate in
executor_stats.db.suggested_query, inside
scheduled_turn_scope: the outbound consent gate and the vaglio
guards apply). The honest outcome lands in applied_effect; if
the turn works, L0/L1 learn on their own from the real turn — no parallel
stores (ADR 0180).UPDATE canonical_query_log SET
state='active' (historical kind: the canonical source is retired, the
handler remains for rows accepted in the past).~/.local/share/metnos/rejected_patterns.jsonl (agent_runtime
reads as HARD CONSTRAINT for the planner).Observer checks metrics post-applied_at and applies grace period (default
7 days, env METNOS_CHANGE_GRACE_DAYS). Rollback triggers:
last_call_ok=False with ≥3 calls;
deprecated by executor_ager.demoted.Physical rollback delegated to change_rollback.py (per kind:
archive synth dir, restore manifest from blob + re-sign, remove alias,
demote state, remove jsonl line).
find_recipesTelos engine SCAMPER suggests: «Extend find_files with
kind=recipe to match .md files in
~/Documents/Recipes.»
| Act | State | Where it happens |
|---|---|---|
| 1. System creates ChangeIntent | PROPOSED | materializer @01:00 from telos_proposals.jsonl |
2. User clicks ✓ | ACCEPTED | POST /admin/changes/{id}/accept |
| 3. Applier modifies manifest + re-sign | APPLIED | change_applier @every_10m |
4. 5 calls to find_files(kind=recipe), all OK |
OBSERVED | change_observer @03:15 when age ≥ 0 |
| 5. After 7d trouble-free | FINALIZED | change_observer on the 7th pass |
If instead after apply Roberto presses ✗ in chat on a
reply using the new arg, observer sees new_rejects≥2 for the target
query and transitions to ROLLED_BACK — physical restore
of the manifest from blob.
?limit=100|500).Accept: application/json (no text/html).python3 -m pytest runtime/tests/test_change_*.py -v
(55 tests).python3 -m runtime.jobs.change_intent_materialize (idempotent).sqlite3 ~/.local/state/metnos/change_intents.sqlite.~/.local/share/metnos/audit/change_{intent_materialize,applier,observer}.jsonl.— aligned with the code.