Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ef855b98 |
+202
-23
@@ -23,26 +23,16 @@
|
|||||||
// warning. The runtime takes an exclusive flock at startup and a second start
|
// warning. The runtime takes an exclusive flock at startup and a second start
|
||||||
// is refused loudly with the holder's pid.
|
// is refused loudly with the holder's pid.
|
||||||
//
|
//
|
||||||
// guards: names WHAT the singleton protects — this program's data directory. The
|
// NOT declared here, on purpose: ENGRAM_DATA_DIR. Its resolution is owned by
|
||||||
// lock lives inside it, so the guard is keyed on the store and not on the word
|
// engram_resolve_data_dir() (el_runtime.c), which defaults to $HOME/.neuron/engram
|
||||||
// "engram": two engrams against the same store cannot both run no matter how the
|
// and fails LOUD rather than silently persisting to an ephemeral directory.
|
||||||
// environment is spelled, and two engrams against DIFFERENT stores are not each
|
// Declaring a default for it here as well would put the data dir's fallback in
|
||||||
// other's business and are not refused. Until 2026-08-16 the lock was keyed on
|
// two places — which is precisely the defect this migration removes (until
|
||||||
// the program name and $TMPDIR, and both of those sentences were false.
|
// 2026-08-15 the reseed backup path carried its own "/tmp/engram" default that
|
||||||
//
|
// disagreed with the resolver, so the pre-destructive safety copy landed in /tmp).
|
||||||
// It names the resolver rather than restating its path, for the same reason
|
|
||||||
// ENGRAM_DATA_DIR is NOT declared as an `env` entry below: engram_resolve_data_dir()
|
|
||||||
// (el_runtime.c) owns that path — it defaults to $HOME/.neuron/engram and fails
|
|
||||||
// LOUD rather than silently persisting to an ephemeral directory. Restating the
|
|
||||||
// default here would give the data dir two owners that can disagree, which is
|
|
||||||
// precisely the defect this migration removes (until 2026-08-15 the reseed backup
|
|
||||||
// path carried its own "/tmp/engram" default that disagreed with the resolver, so
|
|
||||||
// the pre-destructive safety copy landed in /tmp). A guard that resolved the path
|
|
||||||
// its own way could guard a directory the program never writes to.
|
|
||||||
// HOME is likewise not declared: it is a genuine environment read, not a knob.
|
// HOME is likewise not declared: it is a genuine environment read, not a knob.
|
||||||
program "engram" {
|
program "engram" {
|
||||||
singleton: "engram"
|
singleton: "engram"
|
||||||
guards: engram_resolve_data_dir()
|
|
||||||
|
|
||||||
// ── Core server ──
|
// ── Core server ──
|
||||||
env ENGRAM_BIND: String = ":8742"
|
env ENGRAM_BIND: String = ":8742"
|
||||||
@@ -1479,9 +1469,16 @@ fn route_guide_summon(method: String, path: String, body: String) -> String {
|
|||||||
//
|
//
|
||||||
// The SINGLE NODE is the DEGENERATE n=1 case of this SAME operation — not a
|
// The SINGLE NODE is the DEGENERATE n=1 case of this SAME operation — not a
|
||||||
// separate CRUD path:
|
// separate CRUD path:
|
||||||
// write(content) = reframe(region=∅, manifold=[1 node]) (route_write)
|
// write(signal) = realize(signal) → reframe(region=∅, manifold) (route_write)
|
||||||
// supersede(id,new) = reframe(region={id}, manifold=[1 node]) (route_supersede)
|
// supersede(id,new) = reframe(region={id}, manifold=[1 node]) (route_supersede)
|
||||||
// relate(a,b,rel) = the rebind sub-op in isolation (route_create_edge)
|
// relate(a,b,rel) = the rebind sub-op in isolation (route_create_edge)
|
||||||
|
//
|
||||||
|
// CORRECTED 2026-08-16: write was documented above as
|
||||||
|
// "reframe(region=∅, manifold=[1 node])", and the "[1 node]" was not the design
|
||||||
|
// — it was the DEFECT. A node is an OUTPUT of realization, never an INPUT to
|
||||||
|
// it. What arrives at an intake route is a SIGNAL, and how many nodes it
|
||||||
|
// becomes is for the realizer to say, not for the route to assume. See
|
||||||
|
// "INTAKE" below.
|
||||||
// The ONLY anti-pattern is decomposing a region-scale change into a LOOP of
|
// The ONLY anti-pattern is decomposing a region-scale change into a LOOP of
|
||||||
// independent top-level per-node updates. Here the region is the unit: one
|
// independent top-level per-node updates. Here the region is the unit: one
|
||||||
// isolate, one atomic set-replace, one persist, one verify — iterating members
|
// isolate, one atomic set-replace, one persist, one verify — iterating members
|
||||||
@@ -1696,6 +1693,174 @@ fn reframe_core(region: [String], manifold: String, reason: String, do_rebind: I
|
|||||||
",\"keystones_protected\":true}"
|
",\"keystones_protected\":true}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// INTAKE — the ONE door: signal → realization → manifold → store.
|
||||||
|
//
|
||||||
|
// THERE IS NO WRITE NODE. What arrives at an intake route is a SIGNAL. A node
|
||||||
|
// is an OUTPUT of realization, never an INPUT to it. route_write used to say:
|
||||||
|
//
|
||||||
|
// let manifold: String = "[" + body + "]" // the body IS a valid manifold node object
|
||||||
|
//
|
||||||
|
// and hand that to reframe_core. That is not a manifold — it is the request
|
||||||
|
// body wearing the word, and the comment stated the wrong assumption out loud.
|
||||||
|
// It is why a compound signal landed as ONE flat node with ZERO edges. Measured
|
||||||
|
// before this change, on a cp -Rc clone:
|
||||||
|
// POST /api/write {"type":"memory","content":"A cathedral is stone holding a
|
||||||
|
// shape that stone alone would not hold."}
|
||||||
|
// → {"ok":true,"inserted":1,"nodes_added":1,"edges_added":0,...}
|
||||||
|
// GET /api/neighbors/<new id> → [] (read back out, not taken on trust)
|
||||||
|
//
|
||||||
|
// NOTHING IS DECOMPOSED HERE, AND NOTHING MAY EVER BE. transduce(signal,
|
||||||
|
// modality) IS the realization primitive (el_runtime.c: "Manifold",
|
||||||
|
// "Realizers + transduce"). It dispatches through the dlsym realizer registry,
|
||||||
|
// so ADDING A MODALITY IS REGISTERING A REALIZER — never an edit to this file,
|
||||||
|
// and never a patch to the runtime. This function only carries what the
|
||||||
|
// primitive returns into the store, which is the one thing the engram's HTTP
|
||||||
|
// surface has never done: `grep -n 'transduce\|realize\|Manifold\|decompos'
|
||||||
|
// engram/src/server.el` returned exactly one line before this change, a comment.
|
||||||
|
//
|
||||||
|
// GENERAL BY CONSTRUCTION, NOT SPECIAL-CASED TO route_write. Five of the six
|
||||||
|
// intake doors (write, supersede, nodes, neuron/knowledge/capture,
|
||||||
|
// neuron/state-events) are the same hand-written "content string →
|
||||||
|
// engram_node_full → one flat node", differing ONLY in the node_type / tier /
|
||||||
|
// tags they hardcode. Those are parameters here, so each door can be moved onto
|
||||||
|
// this one function as it is transitioned. Only /api/write rides it in this
|
||||||
|
// pass; the rest are listed as remaining work.
|
||||||
|
//
|
||||||
|
// WHEN THERE IS NO ORGAN the signal is stored flat exactly as before, and the
|
||||||
|
// response SAYS SO ("realized":false, "organ":false). Silent flattening is the
|
||||||
|
// actual defect — a caller could not distinguish "nothing decomposed me" from
|
||||||
|
// "I decomposed into one component". el_runtime.c draws the same line at
|
||||||
|
// registration time, between an absent organ and a broken one, for the same
|
||||||
|
// reason: those two must not look alike.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Resolve a component KEY to the node id it was inserted as. Components are
|
||||||
|
// addressed BY KEY, never by index (el_runtime.c, "Manifold"), because the key
|
||||||
|
// is what survives persistence — so relations are resolved by key too.
|
||||||
|
fn key_to_id(keys: [String], ids: [String], key: String) -> String {
|
||||||
|
let n: Int = el_list_len(keys)
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
if str_eq(el_list_get(keys, i), key) { return el_list_get(ids, i) }
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intake_signal(signal: String, modality: String, region: [String],
|
||||||
|
nt_in: String, tier_in: String, tags: String,
|
||||||
|
reason: String, do_rebind: Int) -> String {
|
||||||
|
let n_before: Int = engram_node_count()
|
||||||
|
let e_before: Int = engram_edge_count()
|
||||||
|
let region_n: Int = el_list_len(region)
|
||||||
|
let tomb: String = if region_n > 0 { supersede_set(region, reason) } else { "" }
|
||||||
|
|
||||||
|
// Identity can never be minted through intake — the same rule
|
||||||
|
// insert_manifold_json holds, applied at the one door instead of per-route.
|
||||||
|
let nt: String = if str_eq(nt_in, "") { "Memory" } else { nt_in }
|
||||||
|
if str_eq(nt, "self") { nt = "Memory" }
|
||||||
|
if str_eq(nt, "values") { nt = "Memory" }
|
||||||
|
let tier: String = if str_eq(tier_in, "") { "Working" } else { tier_in }
|
||||||
|
|
||||||
|
let has_organ: Int = realizer_has(modality)
|
||||||
|
let new_ids: [String] = el_list_empty()
|
||||||
|
let keys: [String] = el_list_empty()
|
||||||
|
let ncomp: Int = 0
|
||||||
|
let nrel: Int = 0
|
||||||
|
let realized: Int = 0
|
||||||
|
|
||||||
|
if has_organ > 0 {
|
||||||
|
let m: Manifold = transduce(signal, modality)
|
||||||
|
// A realizer that returns a bare Geometry transduces NOTHING by design
|
||||||
|
// (el_runtime.c) — manifold_is() is the check, so a fingerprinting organ
|
||||||
|
// is not silently mistaken for a decomposing one.
|
||||||
|
if manifold_is(m) > 0 {
|
||||||
|
realized = 1
|
||||||
|
ncomp = manifold_size(m)
|
||||||
|
let i: Int = 0
|
||||||
|
let prev: String = ""
|
||||||
|
while i < ncomp {
|
||||||
|
let key: String = manifold_key(m, i)
|
||||||
|
let role: String = manifold_role(m, i)
|
||||||
|
// The component's OWN geometry, at its own width — this is the
|
||||||
|
// whole point of a manifold over a fingerprint, and it is why
|
||||||
|
// node_attach_geometry is used rather than re-embedding the
|
||||||
|
// component's name as text.
|
||||||
|
let g: Geometry = manifold_geometry(m, i)
|
||||||
|
let ctags: String = "[\"component\",\"role:" + role + "\",\"modality:" + modality + "\"]"
|
||||||
|
let cid: String = engram_node_full(key, nt, key, 0.5, 0.5, 0.9, tier, ctags)
|
||||||
|
let landed: Int = node_attach_geometry(cid, g)
|
||||||
|
let freed: Int = geometry_free(g)
|
||||||
|
new_ids = el_list_append(new_ids, cid)
|
||||||
|
keys = el_list_append(keys, key)
|
||||||
|
// PRESERVED CONTRACT: manifold_member wires the inserted set
|
||||||
|
// into one connected sub-graph, exactly as insert_manifold_json
|
||||||
|
// already did. Not reinvented — reused.
|
||||||
|
if !str_eq(prev, "") { engram_connect(prev, cid, 0.6, "manifold_member") }
|
||||||
|
prev = cid
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
// THE RELATIONS ARE THE CONTENT. Relation weight IS the grounding
|
||||||
|
// (correspondence-and-censorship §1) — it arrives on the edge from
|
||||||
|
// the realizer and nothing here computes or second-guesses it.
|
||||||
|
nrel = manifold_rel_count(m)
|
||||||
|
let j: Int = 0
|
||||||
|
while j < nrel {
|
||||||
|
let fk: String = manifold_rel_from(m, j)
|
||||||
|
let rn: String = manifold_rel_name(m, j)
|
||||||
|
let tk: String = manifold_rel_to(m, j)
|
||||||
|
let w: Float = manifold_rel_weight(m, j)
|
||||||
|
let fid: String = key_to_id(keys, new_ids, fk)
|
||||||
|
let tid: String = key_to_id(keys, new_ids, tk)
|
||||||
|
if !str_eq(fid, "") {
|
||||||
|
if !str_eq(tid, "") {
|
||||||
|
engram_connect(fid, tid, w, rn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j = j + 1
|
||||||
|
}
|
||||||
|
let mfreed: Int = manifold_free(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO ORGAN: store the signal flat, as before — but say so. This is the
|
||||||
|
// pre-existing behaviour preserved verbatim, not a new fallback path.
|
||||||
|
if realized == 0 {
|
||||||
|
let label: String = str_slice(signal, 0, 60)
|
||||||
|
let fid: String = engram_node_full(signal, nt, label, 0.5, 0.5, 0.9, tier, tags)
|
||||||
|
new_ids = el_list_append(new_ids, fid)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inserted: Int = el_list_len(new_ids)
|
||||||
|
let bound: Int = if do_rebind > 0 { rebind_cosine(new_ids, tomb) } else { 0 }
|
||||||
|
let saved: Int = persist_canonical()
|
||||||
|
let new_csv: String = ""
|
||||||
|
let k: Int = 0
|
||||||
|
while k < inserted {
|
||||||
|
let sep: String = if k == 0 { "" } else { "," }
|
||||||
|
new_csv = new_csv + sep + "\"" + el_list_get(new_ids, k) + "\""
|
||||||
|
k = k + 1
|
||||||
|
}
|
||||||
|
let realized_s: String = if realized > 0 { "true" } else { "false" }
|
||||||
|
let organ_s: String = if has_organ > 0 { "true" } else { "false" }
|
||||||
|
return "{\"ok\":true,\"region_superseded\":" + int_to_str(region_n) +
|
||||||
|
",\"tombstone_id\":\"" + tomb + "\"" +
|
||||||
|
",\"inserted\":" + int_to_str(inserted) +
|
||||||
|
",\"new_ids\":[" + new_csv + "]" +
|
||||||
|
",\"edges_rebound\":" + int_to_str(bound) +
|
||||||
|
",\"realized\":" + realized_s +
|
||||||
|
",\"modality\":\"" + modality + "\"" +
|
||||||
|
",\"organ\":" + organ_s +
|
||||||
|
",\"components\":" + int_to_str(ncomp) +
|
||||||
|
",\"relations\":" + int_to_str(nrel) +
|
||||||
|
",\"nodes_added\":" + int_to_str(engram_node_count() - n_before) +
|
||||||
|
",\"edges_added\":" + int_to_str(engram_edge_count() - e_before) +
|
||||||
|
",\"node_count\":" + int_to_str(engram_node_count()) +
|
||||||
|
",\"edge_count\":" + int_to_str(engram_edge_count()) +
|
||||||
|
",\"keystones_protected\":true}"
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/reframe — the universal set-based mutation.
|
// POST /api/reframe — the universal set-based mutation.
|
||||||
// Body: {vantage?, region_ids?(csv), k?, expand?, manifold(json array), reason?, rebind?}
|
// Body: {vantage?, region_ids?(csv), k?, expand?, manifold(json array), reason?, rebind?}
|
||||||
// region_ids (explicit) wins; else cosine-isolate around vantage.
|
// region_ids (explicit) wins; else cosine-isolate around vantage.
|
||||||
@@ -1732,18 +1897,32 @@ fn route_reframe(method: String, path: String, body: String) -> String {
|
|||||||
return reframe_core(region, manifold, reason, do_rebind)
|
return reframe_core(region, manifold, reason, do_rebind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// write — DEGENERATE n=1 of reframe: region=∅, manifold=[1 node]. The SAME
|
// write — INTAKE OF A SIGNAL. Not "reframe with a manifold of one node": the
|
||||||
// reframe_core path. rebind off so the pure-add matches plain node creation.
|
// route no longer decides how many nodes the signal is. It hands the signal to
|
||||||
// POST /api/write {content, node_type?, tier?, tags?}
|
// the realization primitive and stores whatever manifold comes back.
|
||||||
|
//
|
||||||
|
// The line this replaces was:
|
||||||
|
// let manifold: String = "[" + body + "]" // the body IS a valid manifold node object
|
||||||
|
// which asserted that a request body is a manifold. It is not, and that single
|
||||||
|
// assertion is the whole measured defect (1 node, 0 edges, [] neighbors).
|
||||||
|
//
|
||||||
|
// rebind stays off so a pure add still matches plain node creation.
|
||||||
|
// POST /api/write {content, modality?, node_type?, tier?, tags?}
|
||||||
fn route_write(method: String, path: String, body: String) -> String {
|
fn route_write(method: String, path: String, body: String) -> String {
|
||||||
let content: String = json_get_string(body, "content")
|
let content: String = json_get_string(body, "content")
|
||||||
if str_eq(content, "") { return err_json("write: content required") }
|
if str_eq(content, "") { return err_json("write: content required") }
|
||||||
let nt: String = json_get_string(body, "node_type")
|
let nt: String = json_get_string(body, "node_type")
|
||||||
if str_eq(nt, "self") { return err_json("write: identity is write-protected") }
|
if str_eq(nt, "self") { return err_json("write: identity is write-protected") }
|
||||||
if str_eq(nt, "values") { return err_json("write: identity is write-protected") }
|
if str_eq(nt, "values") { return err_json("write: identity is write-protected") }
|
||||||
|
// The modality names which organ to sense with. It is data, never a branch:
|
||||||
|
// a new modality is a realizer_register call somewhere else in the program,
|
||||||
|
// not another endpoint and not another case here.
|
||||||
|
let mod_raw: String = json_get_string(body, "modality")
|
||||||
|
let modality: String = if str_eq(mod_raw, "") { "text" } else { mod_raw }
|
||||||
|
let tier: String = json_get_string(body, "tier")
|
||||||
|
let tags: String = json_get_raw(body, "tags")
|
||||||
let empty: [String] = el_list_empty()
|
let empty: [String] = el_list_empty()
|
||||||
let manifold: String = "[" + body + "]" // the body IS a valid manifold node object
|
return intake_signal(content, modality, empty, nt, tier, tags, "write", 0)
|
||||||
return reframe_core(empty, manifold, "write", 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// supersede — DEGENERATE n=1 of reframe: region={id}, manifold=[1 node]. The
|
// supersede — DEGENERATE n=1 of reframe: region={id}, manifold=[1 node]. The
|
||||||
|
|||||||
+7
-11
@@ -111,18 +111,14 @@ After changing any `.el` source in `el-compiler/src/` (run from the `lang/` dir)
|
|||||||
```bash
|
```bash
|
||||||
# 1. Stage2: current elc compiles the (modified) compiler to C
|
# 1. Stage2: current elc compiles the (modified) compiler to C
|
||||||
./dist/platform/elc elc-cli.el > elc-new.c
|
./dist/platform/elc elc-cli.el > elc-new.c
|
||||||
# 2. Build the new compiler. Link the WHOLE runtime set, not el_runtime.c alone:
|
# 2. Build the new compiler. The C link target is el_runtime.c — it holds the
|
||||||
# el_runtime.c calls into engram_store / engram_vindex / eg_cosine_batch and
|
# engram store + http/json/state impls the compiler output calls. el_runtime.c
|
||||||
# wraps el_seed.c, so a one-file link fails at `ld` with undefined symbols
|
# self-hosts elc on its own; el_seed.c is the (aspirational) seed layer and does
|
||||||
# (verified 2026-08-16 — the previous single-file line in this doc is stale).
|
# NOT compile standalone under clang (missing prototypes for the el_runtime.c
|
||||||
cc -std=c11 -O2 -I runtime -I$(brew --prefix openssl@3)/include \
|
# symbols it wraps — see caveat below), so link el_runtime.c here.
|
||||||
-L$(brew --prefix openssl@3)/lib \
|
cc -std=c11 -I runtime -lcurl -lpthread \
|
||||||
-o dist/platform/elc-new \
|
-o dist/platform/elc-new \
|
||||||
elc-new.c runtime/el_runtime.c runtime/el_seed.c \
|
elc-new.c runtime/el_runtime.c
|
||||||
runtime/engram_cognition.c runtime/engram_geometry.c runtime/engram_reason.c \
|
|
||||||
runtime/engram_store.c runtime/engram_verify.c runtime/engram_vindex.c \
|
|
||||||
runtime/eg_cosine_batch.c runtime/eg_cosine_batch_strategy_cpu.c \
|
|
||||||
-lcurl -lssl -lcrypto -lpthread -lm
|
|
||||||
# 3. Verify self-hosting FIXPOINT (stage3 == stage2 output, byte-identical):
|
# 3. Verify self-hosting FIXPOINT (stage3 == stage2 output, byte-identical):
|
||||||
./dist/platform/elc-new elc-cli.el > elc-verify.c
|
./dist/platform/elc-new elc-cli.el > elc-verify.c
|
||||||
diff elc-new.c elc-verify.c # must be identical
|
diff elc-new.c elc-verify.c # must be identical
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -3295,12 +3295,6 @@ fn cgi_arg(value: String, has_value: Bool) -> String {
|
|||||||
// exit before touching configuration, ports, or any data directory.
|
// exit before touching configuration, ports, or any data directory.
|
||||||
// 2. config declarations — resolve env-or-default, one declaration per entry.
|
// 2. config declarations — resolve env-or-default, one declaration per entry.
|
||||||
// 3. validate LAST — report EVERY missing/ill-typed entry at once, then exit.
|
// 3. validate LAST — report EVERY missing/ill-typed entry at once, then exit.
|
||||||
//
|
|
||||||
// `singleton:` carries its `guards:` expression as its SECOND argument — the
|
|
||||||
// state the lock protects, evaluated here at the process boundary. A singleton
|
|
||||||
// without one does not compile (see below): a lock keyed on the program's name
|
|
||||||
// rather than on its state refuses unrelated instances and permits concurrent
|
|
||||||
// ones, which is not a weaker guard but a wrong one.
|
|
||||||
fn el_bool_arg(b: Bool) -> String {
|
fn el_bool_arg(b: Bool) -> String {
|
||||||
if b { return "EL_INT(1)" }
|
if b { return "EL_INT(1)" }
|
||||||
return "EL_INT(0)"
|
return "EL_INT(0)"
|
||||||
@@ -3312,16 +3306,7 @@ fn emit_program_init(stmt: Map<String, Any>) -> Void {
|
|||||||
let has_singleton: Bool = stmt["has_singleton"]
|
let has_singleton: Bool = stmt["has_singleton"]
|
||||||
if has_singleton {
|
if has_singleton {
|
||||||
let sid: String = stmt["singleton"]
|
let sid: String = stmt["singleton"]
|
||||||
let has_guards: Bool = stmt["has_guards"]
|
emit_line(" el_singleton_acquire(EL_STR(" + c_str_lit(sid) + "));")
|
||||||
if has_guards {
|
|
||||||
let guards_c: String = cg_expr(stmt["guards"])
|
|
||||||
emit_line(" el_singleton_acquire(EL_STR(" + c_str_lit(sid) + "), " + guards_c + ");")
|
|
||||||
} else {
|
|
||||||
// Refuse at COMPILE time. The alternative — emitting a name-keyed
|
|
||||||
// lock — is the defect itself, and it fails silently in the direction
|
|
||||||
// that loses data.
|
|
||||||
emit_line("#error \"singleton '" + sid + "' declares no `guards:` — a singleton must name the state it protects, e.g. `guards: engram_resolve_data_dir()` (spec 18.2)\"")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let entries = stmt["entries"]
|
let entries = stmt["entries"]
|
||||||
let n: Int = native_list_len(entries)
|
let n: Int = native_list_len(entries)
|
||||||
|
|||||||
@@ -1976,18 +1976,6 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
|||||||
// singleton: "id" — process identity. The runtime takes an exclusive
|
// singleton: "id" — process identity. The runtime takes an exclusive
|
||||||
// lock at startup; a SECOND start is refused, loudly,
|
// lock at startup; a SECOND start is refused, loudly,
|
||||||
// instead of two processes sharing one data dir.
|
// instead of two processes sharing one data dir.
|
||||||
// guards: <expr> — WHAT that singleton protects: an expression yielding
|
|
||||||
// the path of the guarded state directory, evaluated at
|
|
||||||
// startup. MANDATORY with `singleton:`, because a lock
|
|
||||||
// keyed on a program's NAME rather than on its STATE is
|
|
||||||
// not a guard — measured 2026-08-16, the name-keyed
|
|
||||||
// version refused unrelated instances (different data
|
|
||||||
// dirs) AND permitted concurrent ones (same data dir,
|
|
||||||
// different $TMPDIR). It is an expression and not a
|
|
||||||
// string so a program can point at the resolver that
|
|
||||||
// already OWNS the path (§18.4) instead of restating
|
|
||||||
// its default here, which would give the path two
|
|
||||||
// owners that can disagree.
|
|
||||||
// env NAME: T = "d" — one configuration entry. Its type and its default
|
// env NAME: T = "d" — one configuration entry. Its type and its default
|
||||||
// are declared ONCE, here, and resolved+validated
|
// are declared ONCE, here, and resolved+validated
|
||||||
// before main() body runs.
|
// before main() body runs.
|
||||||
@@ -2005,8 +1993,6 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
|||||||
let p = expect(tokens, p, "LBrace")
|
let p = expect(tokens, p, "LBrace")
|
||||||
let singleton = ""
|
let singleton = ""
|
||||||
let has_singleton = false
|
let has_singleton = false
|
||||||
let guards_node = { "expr": "Str", "value": "" }
|
|
||||||
let has_guards = false
|
|
||||||
let entries = native_list_empty()
|
let entries = native_list_empty()
|
||||||
// Entry-scratch declared at loop-body level (not inside the branch) so
|
// Entry-scratch declared at loop-body level (not inside the branch) so
|
||||||
// that inner `let` forms compile to assignment rather than a C-scoped
|
// that inner `let` forms compile to assignment rather than a C-scoped
|
||||||
@@ -2062,26 +2048,13 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
|||||||
"required": erequired
|
"required": erequired
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if str_eq(fname, "guards") {
|
// scalar field: `name: "value"`
|
||||||
// guards: <expr> — the STATE the singleton protects.
|
let p = expect(tokens, p, "Colon")
|
||||||
// Parsed as a full expression, not a string literal, so
|
let fval = tok_value(tokens, p)
|
||||||
// it can name the resolver that owns the path
|
let p = p + 1
|
||||||
// (`guards: engram_resolve_data_dir()`) rather than
|
if str_eq(fname, "singleton") {
|
||||||
// duplicating that resolver's default here.
|
let singleton = fval
|
||||||
let p = expect(tokens, p, "Colon")
|
let has_singleton = true
|
||||||
let g_r = parse_expr(tokens, p)
|
|
||||||
let guards_node = g_r["node"]
|
|
||||||
let p = g_r["pos"]
|
|
||||||
let has_guards = true
|
|
||||||
} else {
|
|
||||||
// scalar field: `name: "value"`
|
|
||||||
let p = expect(tokens, p, "Colon")
|
|
||||||
let fval = tok_value(tokens, p)
|
|
||||||
let p = p + 1
|
|
||||||
if str_eq(fname, "singleton") {
|
|
||||||
let singleton = fval
|
|
||||||
let has_singleton = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let k5 = tok_kind(tokens, p)
|
let k5 = tok_kind(tokens, p)
|
||||||
@@ -2097,8 +2070,6 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
|||||||
"name": name,
|
"name": name,
|
||||||
"singleton": singleton,
|
"singleton": singleton,
|
||||||
"has_singleton": has_singleton,
|
"has_singleton": has_singleton,
|
||||||
"guards": guards_node,
|
|
||||||
"has_guards": has_guards,
|
|
||||||
"entries": entries
|
"entries": entries
|
||||||
}, p)
|
}, p)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-96
@@ -19809,84 +19809,22 @@ void log_warn(el_val_t msg_v) {
|
|||||||
* become a convention. */
|
* become a convention. */
|
||||||
static int el_singleton_fd = -1;
|
static int el_singleton_fd = -1;
|
||||||
static char el_singleton_path[1024];
|
static char el_singleton_path[1024];
|
||||||
static char el_singleton_state[1024];
|
|
||||||
|
|
||||||
/* el_singleton_acquire — claim exclusive use of the guarded STATE, or refuse to
|
static const char* el_singleton_dir(void) {
|
||||||
* start. Compiler-injected as the FIRST statement of main() for any program
|
const char* d = getenv("EL_SINGLETON_DIR");
|
||||||
* whose `program` block declares `singleton:` (which must also declare
|
if (d && *d) return d;
|
||||||
* `guards:` — see lang/spec/language.md §18.2).
|
d = getenv("TMPDIR");
|
||||||
*
|
if (d && *d) return d;
|
||||||
* GUARD THE THING, NOT THE NAME.
|
return "/tmp";
|
||||||
*
|
}
|
||||||
* Until 2026-08-16 this lock was keyed on the program's NAME and on $TMPDIR —
|
|
||||||
* `$EL_SINGLETON_DIR|$TMPDIR|/tmp` + `/el-singleton-<name>.lock` — and never
|
/* el_singleton_acquire — claim exclusive process identity, or refuse to start.
|
||||||
* consulted the state it claimed to protect. Its own refusal message said
|
* Compiler-injected as the FIRST statement of main() for any program whose
|
||||||
* "Refusing to start a second instance against the same state" while it had not
|
* `program` block declares `singleton:`. */
|
||||||
* looked at any state. Measured, it failed in BOTH directions:
|
el_val_t el_singleton_acquire(el_val_t id_v) {
|
||||||
*
|
|
||||||
* - FALSE POSITIVE: two engrams against genuinely DIFFERENT data dirs could
|
|
||||||
* not coexist. The second was refused, naming the first's pid — for sharing
|
|
||||||
* a name, not a store.
|
|
||||||
* - FALSE NEGATIVE (the dangerous one): `TMPDIR=/tmp/other` let a second
|
|
||||||
* instance start against the SAME data dir with no complaint. That is
|
|
||||||
* exactly the two-instance data-loss condition the guard exists to prevent,
|
|
||||||
* and the workaround was one environment variable.
|
|
||||||
*
|
|
||||||
* Both are one error: the identity of the resource had been replaced by a label
|
|
||||||
* for it. The fix is to put the lock file INSIDE the state it guards:
|
|
||||||
*
|
|
||||||
* <state>/.el-singleton-<id>.lock
|
|
||||||
*
|
|
||||||
* That placement is the whole mechanism, and it is why there is no hashing, no
|
|
||||||
* canonical-path registry, and no environment variable left to subvert:
|
|
||||||
*
|
|
||||||
* - Same directory => same file => same inode => the flock CONTENDS. There is
|
|
||||||
* no TMPDIR in the key, so there is nothing to change to get past it.
|
|
||||||
* - Different dirs => different files => no contention. Two stores are two
|
|
||||||
* stores; they were never in conflict and are no longer treated as if they
|
|
||||||
* were.
|
|
||||||
* - Different SPELLINGS of one directory — trailing slash, `x/../x`, a symlink
|
|
||||||
* — resolve to the same inode in the kernel's own path walk, so they contend
|
|
||||||
* without this code comparing strings at all. Path canonicalisation here is
|
|
||||||
* for the human-readable message, never for the decision.
|
|
||||||
*
|
|
||||||
* Kept, deliberately, from the version this replaces: it is an flock and not a
|
|
||||||
* pidfile (the kernel releases it on crash and on SIGKILL, so there is no stale
|
|
||||||
* state and therefore no "delete the lock file to get unstuck" ritual), and it
|
|
||||||
* reports the HOLDER'S PID (added because a stale process survived `pkill -f`
|
|
||||||
* and went on answering probes; "already running" is not actionable, a pid is).
|
|
||||||
*
|
|
||||||
* Changed: the message is now TRUE. It says "the same state" because the lock it
|
|
||||||
* failed to take lives in that state, and it names the state it checked. */
|
|
||||||
el_val_t el_singleton_acquire(el_val_t id_v, el_val_t state_v) {
|
|
||||||
const char* id = EL_CSTR(id_v);
|
const char* id = EL_CSTR(id_v);
|
||||||
if (!id || !*id) return EL_NULL;
|
if (!id || !*id) return EL_NULL;
|
||||||
|
|
||||||
/* A singleton with nothing to guard is the defect this function exists to
|
|
||||||
* remove; refuse rather than silently fall back to name-keying. The compiler
|
|
||||||
* rejects `singleton:` without `guards:`, so reaching this is a toolchain
|
|
||||||
* mismatch, not a user mistake — say so. */
|
|
||||||
const char* state = EL_CSTR(state_v);
|
|
||||||
if (!state || !*state) {
|
|
||||||
fprintf(stderr,
|
|
||||||
"[el] FATAL: singleton '%s' was given no state to guard.\n"
|
|
||||||
"[el] A lock keyed on a program's NAME instead of on the state it\n"
|
|
||||||
"[el] protects is not a guard: it refuses unrelated instances and\n"
|
|
||||||
"[el] permits concurrent ones. Declare `guards: <path>` alongside\n"
|
|
||||||
"[el] `singleton:` in the program block (spec §18.2).\n", id);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canonicalise so the operator is told WHICH directory was checked, in one
|
|
||||||
* spelling, whatever spelling they typed. This is a readability measure, not
|
|
||||||
* the mechanism: realpath() may fail (the directory may not exist yet) and
|
|
||||||
* correctness must not depend on it — when it succeeds it names the same
|
|
||||||
* directory, and when it does not we fall back to the path as given and the
|
|
||||||
* kernel's own path walk still collapses the spellings at open() time. */
|
|
||||||
char* rp = realpath(state, NULL);
|
|
||||||
snprintf(el_singleton_state, sizeof(el_singleton_state), "%s", rp ? rp : state);
|
|
||||||
free(rp);
|
|
||||||
|
|
||||||
/* Sanitise the id into a filename. */
|
/* Sanitise the id into a filename. */
|
||||||
char safe[256];
|
char safe[256];
|
||||||
size_t si = 0;
|
size_t si = 0;
|
||||||
@@ -19897,25 +19835,13 @@ el_val_t el_singleton_acquire(el_val_t id_v, el_val_t state_v) {
|
|||||||
safe[si++] = (char)(ok ? c : '-');
|
safe[si++] = (char)(ok ? c : '-');
|
||||||
}
|
}
|
||||||
safe[si] = '\0';
|
safe[si] = '\0';
|
||||||
/* THE MECHANISM: the lock lives inside the state it guards. Two spellings of
|
|
||||||
* one directory name one file; two directories name two files. Note there is
|
|
||||||
* no $TMPDIR and no $EL_SINGLETON_DIR in this path — the escape hatch that
|
|
||||||
* made the guard bypassable is gone because there is nowhere left to put it. */
|
|
||||||
snprintf(el_singleton_path, sizeof(el_singleton_path),
|
snprintf(el_singleton_path, sizeof(el_singleton_path),
|
||||||
"%s/.el-singleton-%s.lock", el_singleton_state, safe);
|
"%s/el-singleton-%s.lock", el_singleton_dir(), safe);
|
||||||
|
|
||||||
int fd = open(el_singleton_path, O_RDWR | O_CREAT, 0644);
|
int fd = open(el_singleton_path, O_RDWR | O_CREAT, 0644);
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
/* Unguardable state. Refusing is the only honest option: starting anyway
|
fprintf(stderr, "[el] FATAL: singleton '%s': cannot open lock file %s: %s\n",
|
||||||
* would mean running unguarded against exactly the store the guard is
|
id, el_singleton_path, strerror(errno));
|
||||||
* here to protect. */
|
|
||||||
fprintf(stderr,
|
|
||||||
"[el] FATAL: singleton '%s': cannot open the lock inside the state it guards.\n"
|
|
||||||
"[el] state: %s\n"
|
|
||||||
"[el] lock: %s (%s)\n"
|
|
||||||
"[el] The guarded directory must exist and be writable. Refusing to\n"
|
|
||||||
"[el] start unguarded against it.\n",
|
|
||||||
id, el_singleton_state, el_singleton_path, strerror(errno));
|
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||||
@@ -19931,14 +19857,11 @@ el_val_t el_singleton_acquire(el_val_t id_v, el_val_t state_v) {
|
|||||||
fprintf(stderr, "[el] FATAL: another instance of '%s' is already running", id);
|
fprintf(stderr, "[el] FATAL: another instance of '%s' is already running", id);
|
||||||
if (holder > 0) fprintf(stderr, " (pid %ld)", holder);
|
if (holder > 0) fprintf(stderr, " (pid %ld)", holder);
|
||||||
fprintf(stderr, ".\n"
|
fprintf(stderr, ".\n"
|
||||||
"[el] state: %s\n"
|
"[el] lock: %s\n"
|
||||||
"[el] lock: %s\n"
|
|
||||||
"[el] Refusing to start a second instance against the same\n"
|
"[el] Refusing to start a second instance against the same\n"
|
||||||
"[el] state. Two writers against one store is data loss, not a\n"
|
"[el] state. Stop the running one and VERIFY it is gone\n"
|
||||||
"[el] warning. Stop the running one and VERIFY it is gone\n"
|
"[el] (ps -p <pid>) before retrying.\n",
|
||||||
"[el] (ps -p %ld) before retrying — or point this instance at a\n"
|
el_singleton_path);
|
||||||
"[el] different state, which is permitted and is not refused.\n",
|
|
||||||
el_singleton_state, el_singleton_path, holder > 0 ? holder : (long)0);
|
|
||||||
close(fd);
|
close(fd);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1091,7 +1091,7 @@ el_val_t __env_get(el_val_t key);
|
|||||||
* All three are COMPILER-INJECTED at the head of main() — they are not meant to
|
* All three are COMPILER-INJECTED at the head of main() — they are not meant to
|
||||||
* be written by hand, which is the point: the guarantee cannot be forgotten at a
|
* be written by hand, which is the point: the guarantee cannot be forgotten at a
|
||||||
* call site because there is no call site. */
|
* call site because there is no call site. */
|
||||||
el_val_t el_singleton_acquire(el_val_t id, el_val_t state); /* §18.2 process identity — keyed on the guarded state */
|
el_val_t el_singleton_acquire(el_val_t id); /* §18.1 process identity */
|
||||||
el_val_t el_config_declare(el_val_t name, el_val_t type,
|
el_val_t el_config_declare(el_val_t name, el_val_t type,
|
||||||
el_val_t deflt, el_val_t has_default,
|
el_val_t deflt, el_val_t has_default,
|
||||||
el_val_t required); /* §18.2 config schema */
|
el_val_t required); /* §18.2 config schema */
|
||||||
|
|||||||
+7
-36
@@ -1133,7 +1133,6 @@ The `program` block is where a concern of this shape is declared once and enforc
|
|||||||
```
|
```
|
||||||
program "engram" {
|
program "engram" {
|
||||||
singleton: "engram"
|
singleton: "engram"
|
||||||
guards: engram_resolve_data_dir()
|
|
||||||
env ENGRAM_BIND: String = ":8742"
|
env ENGRAM_BIND: String = ":8742"
|
||||||
env GUIDE_PORT: Int = "8771"
|
env GUIDE_PORT: Int = "8771"
|
||||||
env ENGRAM_API_KEY: String required
|
env ENGRAM_API_KEY: String required
|
||||||
@@ -1146,50 +1145,24 @@ Grammar:
|
|||||||
|
|
||||||
```ebnf
|
```ebnf
|
||||||
program_block = "program" string "{" { program_field } "}" ;
|
program_block = "program" string "{" { program_field } "}" ;
|
||||||
program_field = singleton_field | guards_field | env_field ;
|
program_field = singleton_field | env_field ;
|
||||||
singleton_field = "singleton" ":" string [ "," ] ;
|
singleton_field = "singleton" ":" string [ "," ] ;
|
||||||
guards_field = "guards" ":" expr [ "," ] ;
|
|
||||||
env_field = "env" ident ":" type
|
env_field = "env" ident ":" type
|
||||||
[ "=" string ] [ "required" ] [ "," ] ;
|
[ "=" string ] [ "required" ] [ "," ] ;
|
||||||
```
|
```
|
||||||
|
|
||||||
`singleton`, `guards` and `env` are **not** reserved words. They are read as identifier token values by the block's own parse loop, so they remain usable as ordinary identifiers everywhere else. `program` is the only keyword this section adds.
|
`singleton` and `env` are **not** reserved words. They are read as identifier token values by the block's own parse loop, so they remain usable as ordinary identifiers everywhere else. `program` is the only keyword this section adds.
|
||||||
|
|
||||||
### 18.2 Process identity — `singleton` and `guards`
|
### 18.2 Process identity — `singleton`
|
||||||
|
|
||||||
`singleton: "id"` with `guards: <expr>` compiles to `el_singleton_acquire("id", <expr>)`, injected as the **first statement of `main()`**, before any user statement runs. `<expr>` evaluates to the path of the **state** the singleton protects.
|
`singleton: "id"` compiles to an `el_singleton_acquire("id")` call injected as the **first statement of `main()`**, before any user statement runs.
|
||||||
|
|
||||||
**`guards:` is mandatory.** A `singleton:` without one is a compile error. This is not defensive strictness; it is the correction of a defect measured in this tree on 2026-08-16, and the rule the rest of this section exists to state:
|
The runtime takes an exclusive non-blocking `flock` on `<dir>/el-singleton-<id>.lock`, where `<dir>` is `$EL_SINGLETON_DIR`, else `$TMPDIR`, else `/tmp`. On success it writes its pid and holds the descriptor open for the life of the process. On contention it **refuses to start**: it reports the holder's pid, names the lock file, and exits 1.
|
||||||
|
|
||||||
> **Guard the thing, not the name.** A lock that protects state must be keyed on the state.
|
Two properties are deliberate:
|
||||||
|
|
||||||
Until that date the lock was `<dir>/el-singleton-<id>.lock` where `<dir>` was `$EL_SINGLETON_DIR`, else `$TMPDIR`, else `/tmp`. It was keyed on the program's **name** and on a temp directory, and it never consulted the state it claimed to protect — while its own refusal message read *"Refusing to start a second instance against the same state."* Measured, it failed in **both** directions:
|
- **It is a lock, not a pidfile.** The kernel releases an `flock` when the owning process dies — including on `SIGKILL` and on crash. There is therefore no stale-lock state, and so no "delete the lock file to get unstuck" recovery ritual. Such a ritual would itself be a convention, which is the thing this section exists to remove.
|
||||||
|
|
||||||
| Situation | Correct answer | Name-keyed lock gave |
|
|
||||||
|---|---|---|
|
|
||||||
| same data dir, same `$TMPDIR` | refuse | refuse ✅ |
|
|
||||||
| same data dir, different `$TMPDIR` | refuse | **started** ❌ — the two-writer data-loss condition, defeated by one environment variable |
|
|
||||||
| different data dirs, same `$TMPDIR` | both start | **refused**, naming an unrelated pid ❌ |
|
|
||||||
| same dir spelled differently, different `$TMPDIR` | refuse | **started** ❌ |
|
|
||||||
|
|
||||||
Both failure directions are one error: the identity of a resource had been replaced by a label for it. The false negative is the dangerous one — a guard whose bypass is `TMPDIR=/tmp/other` is not a guard.
|
|
||||||
|
|
||||||
**The mechanism.** The lock file lives **inside the guarded directory**: `<state>/.el-singleton-<id>.lock`. The runtime takes an exclusive non-blocking `flock` on it, writes its pid, and holds the descriptor open for the life of the process.
|
|
||||||
|
|
||||||
That single placement decision is the whole fix, and it is why there is no hashing, no canonical-path registry, and no environment variable left to subvert:
|
|
||||||
|
|
||||||
- **Same directory** ⇒ same file ⇒ same inode ⇒ the `flock` contends. `$TMPDIR` is not in the key, so there is nothing to change to get past it. `$EL_SINGLETON_DIR` no longer exists.
|
|
||||||
- **Different directories** ⇒ different files ⇒ no contention. Two stores are two stores; they were never in conflict, and are no longer treated as if they were.
|
|
||||||
- **Different spellings of one directory** — trailing slash, `x/../x`, a symlink — resolve to the same inode during the kernel's own path walk, so they contend without this code comparing strings. Path canonicalisation happens only to make the diagnostic name one directory in one spelling; the *decision* never depends on it.
|
|
||||||
- **An unguardable state** — the directory is missing, or read-only — is a **refusal**, not a fallback. Starting unguarded against the store the guard exists to protect is the failure being removed.
|
|
||||||
|
|
||||||
**Why `guards:` is an expression and not a string.** The runtime cannot know, generically, which environment variable holds an arbitrary program's state; and a program whose state path already has an owner must not restate it. The engram's data dir is resolved by `engram_resolve_data_dir()`, which owns both the `$ENGRAM_DATA_DIR` read and the `$HOME/.neuron/engram` fallback (§18.4). Writing `guards: engram_resolve_data_dir()` points the guard at that owner. A `guards:` that took a string would force the path's default to be written down twice, and a guard that resolved the path its own way could end up locking a directory the program never writes to — the same two-owners defect §18.4 exists to prevent.
|
|
||||||
|
|
||||||
Three properties are deliberate:
|
|
||||||
|
|
||||||
- **It is a lock, not a pidfile.** The kernel releases an `flock` when the owning process dies — including on `SIGKILL` and on crash. There is therefore no stale-lock state, and so no "delete the lock file to get unstuck" recovery ritual. Such a ritual would itself be a convention, which is the thing this section exists to remove. (A lock file left behind inside a copied data directory — `cp -Rc` and friends — is inert: it carries no lock, only a stale pid string that the next holder overwrites.)
|
|
||||||
- **It reports the holder's pid.** "Already running" is not actionable. A pid is. This is the direct answer to the observed failure where a stale process survived a `pkill` and went on answering probes.
|
- **It reports the holder's pid.** "Already running" is not actionable. A pid is. This is the direct answer to the observed failure where a stale process survived a `pkill` and went on answering probes.
|
||||||
- **The message is true.** It names the state it checked and the lock it failed to take, and it says "the same state" only because the lock it contended for is *in* that state. A diagnostic that asserts a check that did not happen is worse than no diagnostic: it is what let the name-keyed version read as correct for as long as it did.
|
|
||||||
|
|
||||||
Refusal is loud and total. It is not a warning, and the program does not continue degraded. This matters more than it looks: today a second engram whose `bind()` fails merely *returns* from `http_serve` — after it has already replayed the WAL and written boot-time backup files — and then exits **0**, indistinguishable from a clean run. `singleton` refuses before the first side effect.
|
Refusal is loud and total. It is not a warning, and the program does not continue degraded. This matters more than it looks: today a second engram whose `bind()` fails merely *returns* from `http_serve` — after it has already replayed the WAL and written boot-time backup files — and then exits **0**, indistinguishable from a clean run. `singleton` refuses before the first side effect.
|
||||||
|
|
||||||
@@ -1211,8 +1184,6 @@ Some values look like configuration and are not. `ENGRAM_DATA_DIR` already has a
|
|||||||
|
|
||||||
The rule: **a variable belongs in the program block when the block would be its only owner.** If a resolver already owns it, leave it there.
|
The rule: **a variable belongs in the program block when the block would be its only owner.** If a resolver already owns it, leave it there.
|
||||||
|
|
||||||
This is also why `guards:` (§18.2) takes an expression: it lets the block *reference* the existing owner — `guards: engram_resolve_data_dir()` — rather than become a second one.
|
|
||||||
|
|
||||||
`HOME` is likewise not configuration. It is an environment fact, and stays a raw `env()` read.
|
`HOME` is likewise not configuration. It is an environment fact, and stays a raw `env()` read.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user