kanon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b9d154f9d3f8b311fe5b617c77b52aec339b2a61c906f788fcadb430f457749
4
+ data.tar.gz: 898efbcb49ce65f1b80c0e58b0b0ddcdbfd1c9946285272e153452611b757116
5
+ SHA512:
6
+ metadata.gz: db7a72555e10af6a7ab0ca3568e5044b2fc291bdb6767865c61afd8eba87d42b04d10afdfead728a44971bc8bcfcd60acd8a768e0400fcfaa9d093f3ce32d335
7
+ data.tar.gz: 5949ad82bef351c5025a90ee6f25ef5eaa023f5277faac5f3a812f55040e6ff0278728af1bb4cf2050f3cb781465c79415447ee704e878dcffaf8fab002f6cd3
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - A YAML file becomes a deeply frozen tree of nodes, read once per process, with
6
+ dotted access, `[]`, `dig`, `key?`, `keys` and `to_h`.
7
+ - A file is read through the section named after the current environment, and read
8
+ whole when it carries no such section. A single root key leaves the data only
9
+ when it holds a hash of its own.
10
+ - Values that belong to the environment are declared in the same file. A name
11
+ derives from the path, an array index joining it as a segment of its own; `env`
12
+ writes the name out instead, `default` gives a fallback.
13
+ - A value is mandatory unless it carries a `default` or `optional: true`. A read
14
+ that misses one stops the application, naming every absent variable at once. An
15
+ empty variable counts as absent.
16
+ - `type` casts to `string`, `integer`, `float`, `boolean` or `list`; without it
17
+ the class of the default decides, and a `type` written and left empty is refused
18
+ rather than ignored. Integers are read as decimal, so a leading zero is not
19
+ octal.
20
+ - A typo in a key raises `NoMethodError` listing the available keys, never a
21
+ silent `nil`.
22
+ - `expected_variables` reports the names a file reads and whether each is
23
+ mandatory. It walks the file the way a read does with nothing behind it, so it
24
+ refuses a file that could not load instead of reporting a clean list.
25
+ - Everything the loader raises descends from `Kanon::Error`, including a name no
26
+ shell could set, two paths reading one variable, two keys that differ only by
27
+ quoting, a file that cannot be opened, and a directory or environment set to
28
+ nothing.
29
+ - Inspection, serialization and error messages carry key names, classes and
30
+ lengths — never values. `Marshal.dump` refuses, and a wrapped failure drops the
31
+ exception that caused it, so the text that broke a cast or a template tag cannot
32
+ reach a log.
33
+ - ERB runs before YAML is parsed. Parsing goes through `safe_load` on every psych
34
+ version.
35
+ - The format is specified in `docs/specification.md` without reference to Ruby,
36
+ and the reasons behind it are in `docs/decisions.md`.
37
+ - Ruby 2.7 and newer. Nothing outside the standard library.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maksimenko Pavel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # Kanon
2
+
3
+ [![CI](https://github.com/MaksimenkoPG/kanon/actions/workflows/ci.yml/badge.svg)](https://github.com/MaksimenkoPG/kanon/actions/workflows/ci.yml)
4
+
5
+ One way to read application configuration: a YAML file becomes a frozen tree of
6
+ nodes, and the values that belong to the environment are declared in that same
7
+ file.
8
+
9
+ ```ruby
10
+ Kanon[:service].token
11
+ ```
12
+
13
+ ## What it is for
14
+
15
+ Reading configuration tends to accumulate: a framework helper in one place,
16
+ `YAML.load` with ERB in another, `YAML.load_file` inside a class in a third. None
17
+ of them caches, so global constants appear for the sole purpose of reading a file
18
+ once. Access forms multiply, and a typo returns `nil` — which is how a credential
19
+ is silently dropped and an application boots into an outage.
20
+
21
+ So the defaults here are the opposite ones:
22
+
23
+ - **A value is mandatory unless declared otherwise.** A missing one brings the
24
+ application down at boot, naming every absent variable at once.
25
+ - **A typo raises.** `NoMethodError` with the available keys in the message,
26
+ never a silent `nil`.
27
+ - **What was read cannot change.** The tree is deeply frozen and read once per
28
+ process.
29
+ - **The file states which variables exist.** That list can be extracted without
30
+ reading the environment, so a pre-boot check needs no grammar of its own.
31
+ - **A secret never leaves through diagnostics.** Inspection, serialization and
32
+ error messages carry key names, classes and lengths — never values.
33
+
34
+ ## Installation
35
+
36
+ ```ruby
37
+ gem 'kanon'
38
+ ```
39
+
40
+ Ruby 2.7 or newer. Nothing outside the standard library: `yaml`, `erb`, `monitor`,
41
+ and `json` when `to_json` is called.
42
+
43
+ ## A configuration file
44
+
45
+ ```yaml
46
+ default: &default
47
+ :service:
48
+ :endpoint: "https://service.example.com"
49
+ :token: ~
50
+ :timeout: { default: 5 }
51
+ :recipients: { type: list, default: [ops@example.com] }
52
+ :verbose: { default: false }
53
+
54
+ production:
55
+ <<: *default
56
+ ```
57
+
58
+ The variables it expects:
59
+
60
+ ```sh
61
+ SERVICE_TOKEN=a-real-token
62
+ SERVICE_RECIPIENTS=alice@example.com,bob@example.com
63
+ ```
64
+
65
+ What the application gets:
66
+
67
+ ```ruby
68
+ Kanon[:service].token # => "a-real-token"
69
+ Kanon[:service].recipients # => ["alice@example.com", "bob@example.com"]
70
+ Kanon[:service].timeout # => 5
71
+ Kanon[:service].verbose # => false
72
+ ```
73
+
74
+ **Which part of the file is read.** A file is read through the section named after
75
+ the current environment; one whose top level carries no such key is read whole, so
76
+ a list or a dictionary needs no section wrapper around it. A single root key
77
+ becomes the prefix of every variable name below it, and leaves the data when what
78
+ it holds is a hash of its own — hence `Kanon[:service].token` above, not
79
+ `.service.token`. Over a list or over a declaration it stays in the data and only
80
+ lends its name, and several root keys all stay, each prefixing its own subtree.
81
+
82
+ **What a scalar means.** A scalar holding a value is a constant of the file and
83
+ never looks at the environment. An empty one means "expected and not given", under
84
+ a name built from the path with levels joined by a single `_`: `service` → `token`
85
+ reads `SERVICE_TOKEN`, and a nested `pool` → `size` would read
86
+ `SERVICE_POOL_SIZE`.
87
+
88
+ **What a hash means.** A hash whose keys all come from the four words below
89
+ declares a value the environment may provide, so `timeout` stays `5` until
90
+ `SERVICE_TIMEOUT` says otherwise.
91
+
92
+ - **`env`** — the variable name, written out instead of derived from the path.
93
+ - **`default`** — the value when the variable is absent, cast the same way a
94
+ variable is.
95
+ - **`type`** — one of `string`, `integer`, `float`, `boolean`, `list`. Writing the
96
+ key and leaving it empty is refused rather than ignored; leaving it out lets the
97
+ class of the `default` decide.
98
+ - **`optional: true`** — the only way to let a value be missing and still boot.
99
+ Only a literal `true` counts: the string `'false'` is truthy in Ruby and would
100
+ otherwise switch the flag on. An empty string written as the `default` is a
101
+ different thing — it is a value, and it reaches the consumer.
102
+
103
+ **Two forms whose sides look different.** A list is a real array in the file and
104
+ one comma separated string in the environment, spaces around the commas ignored;
105
+ an array default implies `type: list`. A variable that leaves no items after
106
+ parsing, `,,` for instance, counts as no value at all and fails the boot rather
107
+ than falling back to the default. A boolean takes `1`, `true`, `yes`, `on` and
108
+ `0`, `false`, `no`, `off` in any case, stopping the boot on anything else and
109
+ naming what it does accept. Numbers and strings hold no such surprise —
110
+ `SERVICE_TIMEOUT=10` yields the integer `10`, and a leading zero is not octal.
111
+
112
+ An array written out in the file is a different thing entirely from `type: list`,
113
+ and the next section is about it.
114
+
115
+ **How the file is processed.** ERB runs before YAML is parsed, so
116
+ `<%= ENV.fetch(...) %>` needs no marker, both styles mix in one file, and control
117
+ tags (`<% if %> ... <% end %>`) may build the structure of the file itself. YAML is
118
+ then parsed with `safe_load` on every psych version: symbol keys and `<<: *default`
119
+ anchors work, while `!ruby/...` tags and types outside the allowed list are never
120
+ built.
121
+
122
+ ### Arrays
123
+
124
+ An index is a path segment like any other key, so a value inside an array is
125
+ named after the position it sits at:
126
+
127
+ ```yaml
128
+ :service:
129
+ :queues:
130
+ - :name: ~ # SERVICE_QUEUES_0_NAME
131
+ - :name: ~ # SERVICE_QUEUES_1_NAME
132
+ :matrix:
133
+ - [~, ~] # SERVICE_MATRIX_0_0, SERVICE_MATRIX_0_1
134
+ ```
135
+
136
+ Depth is not limited; one segment joins the name per level. A file whose root is
137
+ an array has no root key to borrow, so it takes the name of the file — the same
138
+ name that wraps it into a node:
139
+
140
+ ```yaml
141
+ # endpoints.yml
142
+ - :url: ~ # ENDPOINTS_0_URL, read back as .endpoints[0].url
143
+ ```
144
+
145
+ The position is the entire name, which makes these names brittle. Insert an
146
+ element at the head and every name below it shifts by one: the value meant for one
147
+ element lands in its neighbour, and the boot stops only if some name in the
148
+ shifted range holds nothing. Reordering a list becomes an edit of the environment,
149
+ not of the file alone.
150
+
151
+ So an array the environment fills is usually better declared than written out.
152
+ `type: list` gives the whole list one stable name, and `env` gives a single
153
+ element a name of its own:
154
+
155
+ ```yaml
156
+ :service:
157
+ :queues:
158
+ - :name: { env: PRIMARY_QUEUE }
159
+ - :name: { env: BACKUP_QUEUE }
160
+ ```
161
+
162
+ An array whose values are all in the file asks nothing of the environment and
163
+ carries none of this.
164
+
165
+ ## The interface
166
+
167
+ - **`Kanon.directory`, `Kanon.environment`** — what is in force right now.
168
+ - **`Kanon.directory = path`** — where the files live, `config` until set.
169
+ - **`Kanon.environment = name`** — which section to read, taken from `APP_ENV`,
170
+ then `RAILS_ENV`, then `RACK_ENV`, else `development`; an empty variable is
171
+ skipped in favour of the next one, and surrounding whitespace is trimmed off
172
+ whichever name wins. Setting either of the two empties the cache.
173
+ - **`Kanon[:service]`** — the node from the cache, the same object every time. On
174
+ a miss it falls back to `load_config`, so a cleared cache heals itself.
175
+ - **`Kanon.load_config(:service)`** — reads once and remembers; later calls return
176
+ that same object.
177
+ - **`Kanon.load_configs(:service, :storage)`** — the same for several names at
178
+ once, returning `{ service: node, storage: node }`.
179
+ - **`Kanon.read_config(:service)`** — reads the file again and leaves the cache
180
+ alone. A new object with equal data, so compare readings by value.
181
+ - **`Kanon.expected_variables(:service)`** — the names the file reads and whether
182
+ each is mandatory, `{ 'SERVICE_POOL_SIZE' => false }`, without reading one of
183
+ them. It walks the file the way a read does, so a file that could not load is
184
+ refused here too instead of reporting a clean list.
185
+ - **`Kanon.loaded_files`** — which files the cache holds.
186
+
187
+ Every reader that resolves values stops the application when a mandatory one is
188
+ missing, naming every absent variable at once; `expected_variables` reads none of
189
+ those variables and never fails that way — what it refuses is a file the loader
190
+ could not read at all. One `load_configs` at boot turns this into a single check at
191
+ startup instead of a failure at first use.
192
+
193
+ A reader hands back a node, never a hash:
194
+
195
+ - **`node.token`** — a key written out in the code. A typo raises `NoMethodError`
196
+ listing the keys that are there.
197
+ - **`node[:token]`, `node['token']`** — a key computed at runtime, as a symbol or
198
+ a string.
199
+ - **`node.dig(:pool, :size)`** — the same at depth, and into arrays by index.
200
+ - **`node.key?(:token)`, `node.keys`** — what the node holds.
201
+ - **`node.to_h`** — a copy for a third party that needs a hash. Its containers are
202
+ new and unfrozen; leaf strings stay the frozen ones of the tree.
203
+
204
+ What any implementation of this format must do — the grammar of a declaration, how
205
+ a variable name is derived, the casting table, the order of checks — is in
206
+ [docs/specification.md](docs/specification.md). Why it is shaped that way, and the
207
+ known edges it leaves, are in [docs/decisions.md](docs/decisions.md).
208
+
209
+ ## What the loader refuses
210
+
211
+ Every name below lives under `Kanon::` and descends from `Kanon::Error`.
212
+
213
+ - **`FileMissing`** — the file is absent, or the name is not a bare file name at
214
+ all.
215
+ - **`InvalidSource`** — the file exists but cannot be read: a directory or a file
216
+ the process may not open, broken YAML, an alias with no anchor, a failing ERB
217
+ tag, a forbidden YAML type, a document that refers to itself through an alias,
218
+ or two keys that become one when they turn into symbols.
219
+ - **`EmptyDirectory`** — the directory was set to nothing: `nil`, or a string with
220
+ no more than whitespace in it.
221
+ - **`EmptyEnvironment`** — the environment was set to nothing, by the same
222
+ measure.
223
+ - **`MissingValue`** — a mandatory value is absent or empty, naming every such
224
+ variable at once.
225
+ - **`InvalidValue`** — a value does not fit its type, or a declaration names a
226
+ type outside the five.
227
+ - **`InvalidDeclaration`** — a declaration carries an unsupported key.
228
+ - **`InvalidVariableName`** — a name no shell could set, derived or written out
229
+ in `env`: anything but a letter or `_` followed by letters, digits and `_`.
230
+ - **`ConflictingVariable`** — two paths read one variable, derived or explicit.
231
+ - **`UnknownKey`** — an unknown key in `[]`, a `dig` that ran into a scalar, an
232
+ index past the end of an array.
233
+ - **`ReservedKey`** — a key named after a node method, which would otherwise
234
+ shadow the value.
235
+
236
+ Outside that family the loader lets Ruby speak: `NoMethodError` for a typo in a
237
+ key, listing the keys that are there, and `FrozenError` for an attempt to change
238
+ what was read.
239
+
240
+ An empty string counts as an absent value. No message of Kanon's own prints a
241
+ value: a failing cast reports the class and the length instead. Names it does
242
+ print — a key, a variable, a declared type — so a secret an ERB tag put in one of
243
+ those places reaches the message like any other name.
244
+
245
+ Reserved keys are every public instance method the node has: its own (`[]`,
246
+ `dig`, `key?`, `keys`, `to_h`, `==`, `eql?`, `hash`, `inspect`, `as_json`,
247
+ `to_json`, `encode_with`, `marshal_dump`, `instance_variables`) and all of
248
+ `Object`'s, `class` and `dup` and `send` and `freeze` and `tap` among them. Private
249
+ `Kernel` methods such as `format` or `timeout` are free to be keys.
250
+
251
+ ## What never leaves through diagnostics
252
+
253
+ `inspect`, `as_json`, `to_json` and `to_yaml` print key names only,
254
+ `instance_variables` is empty and `Marshal.dump` refuses, so a tree of secrets
255
+ reaches neither a JSON response, nor a log, nor a queue. A typo reports what is
256
+ there without any value: `undefined method 'tokn' for #<Kanon::Node keys: url,
257
+ username, token>`.
258
+
259
+ A failure the library wraps drops the exception that caused it, so the offending
260
+ text cannot reach a log through `cause`, through `full_message` or through an
261
+ exception tracker that walks the chain. Ruby's own `FrozenError` is the exception
262
+ to all of this: an attempt to change a leaf prints the value it refused to change,
263
+ and nothing here can stop it.
264
+
265
+ One hole cannot be closed from here: `Oj.dump` in `:object` mode reads instance
266
+ variables through the C API, past any Ruby method, and prints the values. That
267
+ holds for every object of an application, not only these; the cure is
268
+ `Oj.default_options = { mode: :compat }` on the application side.
269
+
270
+ ## What it is not
271
+
272
+ - **Not a validator.** There is casting and there is mandatoriness; format,
273
+ ranges and allowed values are out of scope.
274
+ - **Not hot reload.** A config is read once per process; changing a value means
275
+ restarting.
276
+ - **Not a secret store.** Values arrive from the environment; rotation and
277
+ permissions belong to the infrastructure.
278
+ - **Not a way to express structures through the environment.** Nested structures
279
+ live in the file. A variable overrides a scalar, or a flat list of scalars
280
+ given as a comma separated string.
281
+
282
+ One edge is worth knowing before converting a file: a hash of data whose keys all
283
+ come from the declaration vocabulary is read as a declaration, and a key named
284
+ `env` makes one on its own. The rest are in
285
+ [docs/decisions.md](docs/decisions.md).
286
+
287
+ Reading through a node costs about five times a plain hash read, and about seven
288
+ times when every read goes through `Kanon[...]`. It shows only in loops of hundreds
289
+ of thousands of reads — on a hot path take the node into a local variable once. The
290
+ numbers come from `script/benchmark.rb`, which needs no framework:
291
+ `ruby -Ilib script/benchmark.rb`.
292
+
293
+ ## License
294
+
295
+ MIT. See [LICENSE](LICENSE).
data/docs/decisions.md ADDED
@@ -0,0 +1,176 @@
1
+ # Kanon: decisions and their reasons
2
+
3
+ Why the format and the library are shaped the way they are. What an
4
+ implementation must do is in [specification.md](specification.md); this document
5
+ never restates a rule, only the thinking behind it.
6
+
7
+ ## Principles
8
+
9
+ Break any of these and the solution stops being itself.
10
+
11
+ **1. Configuration is data, not code.** Composition, defaults and types live in
12
+ the file. Changing a value never means changing the application, and the whole
13
+ shape of a configuration is visible in one place.
14
+
15
+ **2. One way to read, one shape of access.** A node always comes out. There is no
16
+ second entry point handing out a raw mapping, and the node never pretends to be
17
+ one — partial resemblance hides a forgotten conversion until it breaks somewhere
18
+ else.
19
+
20
+ **3. What was read cannot change.** Immutability is what makes "read once" safe to
21
+ rely on: a tree that nobody can edit is the same tree wherever it is passed. The
22
+ copy handed to a third party has left the configuration and lives by that party's
23
+ rules.
24
+
25
+ **4. Read once, on first use.** Laziness exists so that a configuration the
26
+ application does not need cannot keep it from starting. Caching exists so that a
27
+ crowd of global constants does not appear for the sole purpose of reading a file
28
+ once.
29
+
30
+ **5. Failure is loud and early.** A missing value brings the application down at
31
+ boot naming every absent variable at once, not one per restart. A typo in a key
32
+ is an error listing what is there. The silent empty value is what all of this
33
+ exists to prevent: a credential quietly dropped is an outage that starts long
34
+ before anyone reads a log.
35
+
36
+ **6. The file states which variables exist.** The structure is the single source
37
+ of truth about what must be provided, and that list can be extracted without
38
+ reading any of it, so a check before boot needs no grammar of its own.
39
+
40
+ **7. No value leaves through the library's own diagnostics.** Names do — of keys,
41
+ of variables, of declared types — because a refusal nobody can act on is worse than
42
+ one that names what to fix, and a name built by a template tag is printed like any
43
+ other. What the host language says on its own is outside this: the error Ruby
44
+ raises for an attempt to change an immutable leaf prints the value it refused to
45
+ change, and nothing in the library can stop it.
46
+
47
+ **8. No framework underneath.** The standard library only, with the directory and
48
+ the environment injected from outside. Without that there is no library, only a
49
+ piece of somebody's application.
50
+
51
+ **9. A configuration class is optional.** It appears only where something has to
52
+ be computed. Reading data takes no code at all.
53
+
54
+ **10. Whatever was read becomes one shape.** The consumer cannot tell where a
55
+ value came from.
56
+
57
+ ## Settled
58
+
59
+ **YAML, at a cost that is worth naming.** Six complications in the specification
60
+ exist only because the format is YAML: a second document after `---`, a
61
+ self-referring alias, an alias with no anchor, a forbidden tagged type, two keys
62
+ that differ only by a leading colon, and what a file built on `default: &default`
63
+ does when it is read outside its sections. A simpler format would carry none of
64
+ them.
65
+
66
+ It is still the right base. The library is meant to read configuration files a
67
+ project already has, with the smallest possible edit, and such files are built on
68
+ `default: &default` anchors and carry template tags. A format without anchors turns
69
+ adopting the library into rewriting every file it is meant to read. YAML also
70
+ comes with the standard library, while every alternative costs a dependency and
71
+ with it principle 8.
72
+
73
+ **Domain dictionaries do not belong to this layer.** Lists of types, tax tables and
74
+ similar data are the same on every installation and change together with the code.
75
+ Principles 5 and 6 work against them: a variable name would be derived from a data
76
+ key, an empty entry would read as a missing variable, and arbitrary key text would
77
+ be normalised. Dropping the demand for a section made such a file readable; it did
78
+ not make reading it a good idea.
79
+
80
+ **Values come from the environment, and from nothing else.** A pluggable source —
81
+ a setter in place of the single environment lookup — was weighed and left out.
82
+ Secret stores are reached before the process starts, by tools that export what they
83
+ fetched, or by the secret injection of container platforms, and their values arrive
84
+ as ordinary variables. One deployment would justify the seam: one forbidden to put
85
+ secrets into the process environment at all. Until such a deployment exists the
86
+ seam is an abstraction with a single implementation, and adding it later breaks
87
+ nothing — the setter is new surface, not changed surface.
88
+
89
+ **A derived name has to be one a shell could set.** Nothing else can satisfy it,
90
+ so a name outside `[A-Za-z_][A-Za-z0-9_]*` is refused at boot rather than asked
91
+ for and never answered.
92
+
93
+ **A marker for declarations was rejected.** The one way to close the edge below
94
+ where data can look like a declaration is to demand a marker on every declaration.
95
+ That makes the common case pay for the rare one, and every existing file pay for a
96
+ collision most of them never hit.
97
+
98
+ ## The Ruby surface
99
+
100
+ ```ruby
101
+ Kanon[:name] # node from the cache, load_config on a miss
102
+ Kanon.read_config(:name) # node, reads the file every time
103
+ Kanon.load_config(:name) # node, reads once and remembers
104
+ Kanon.load_configs(:one, :two) # { one: node, two: node }
105
+ Kanon.expected_variables(:name) # { name => mandatory? }, reads no such variable
106
+ Kanon.directory # and Kanon.directory = path
107
+ Kanon.environment # and Kanon.environment = name
108
+ Kanon.loaded_files # diagnostics
109
+ ```
110
+
111
+ `read` and `load` split "read" from "read and remember"; `[]` is the consumer's
112
+ form, honest because everything is warmed up at boot.
113
+
114
+ Contracts that are easy to lose in a rewrite:
115
+
116
+ - `load_configs` returns a name-to-node mapping on purpose: a one-letter typo
117
+ (`load_configs` instead of `load_config`) then fails on the same line.
118
+ - `read_config` returns a new object with equal data every time. Only
119
+ `load_config` guarantees identity, so compare configurations by value.
120
+ - `expected_variables` walks the file through the same code a read walks. A walk
121
+ of its own would drift from the real one, and did.
122
+
123
+ ## Known edges
124
+
125
+ Consequences of the rules, documented rather than fixed. A conforming
126
+ implementation reproduces them.
127
+
128
+ **A data key can be mistaken for an environment section.** Not demanding a section
129
+ costs one silent case: a file whose top-level key happens to be `test` or
130
+ `production` is truncated to that branch without a word. A file built around a
131
+ `default: &default` anchor gives itself away instead, because every copy of the
132
+ anchor resolves at once — the read stops either on the values those copies expect,
133
+ named with the section in front (`DEFAULT_SERVICE_TOKEN` beside
134
+ `TEST_SERVICE_TOKEN`), or on two paths reading one variable where a declaration
135
+ carries an explicit `env`.
136
+
137
+ **A hash of data can look like a declaration.** Real data uses the four vocabulary
138
+ words too. Most such collisions announce themselves: a `type` outside the five
139
+ names is refused, and a declaration that demands a value stops the boot naming a
140
+ variable nobody meant to set. It passes quietly only when the declaration it
141
+ imitates demands nothing — it carries a `default`, or it is `optional` — and the
142
+ subtree is then replaced by that default or by nothing. One key from outside the
143
+ vocabulary keeps a mapping as data, and that is the escape when converting a file —
144
+ but not for a mapping carrying `env`, which is a declaration on that key alone.
145
+
146
+ **A misspelt declaration key turns the declaration into data.** `{ dfault: 5 }`
147
+ carries no word of the vocabulary, so it is data, reads back as data and expects
148
+ nothing. "A typo raises" covers a key read out of the tree; nothing can tell a near
149
+ miss from a dictionary that happens to be worded that way.
150
+
151
+ **Only the first document of a file is read.** Everything past a `---` separator is
152
+ dropped without a word.
153
+
154
+ **The shape of the tree depends on how much data is in the file.** A single root
155
+ key over a mapping of its own is dropped, so removing the last but one entry of a
156
+ dictionary changes every reading path.
157
+
158
+ **A value with a default hides a vanished variable.** The default takes over
159
+ silently; the report of expected variables names it, but nothing compares that list
160
+ against the example environment file.
161
+
162
+ **An index is a position, not a name.** Inserting an element into a sequence
163
+ renames everything below it. The file keeps parsing and the shifted names keep
164
+ resolving, so the mistake surfaces as wrong values rather than as a failure. A name
165
+ that must survive reordering is written out with `env`.
166
+
167
+ **Whitespace counts differently on the two sides.** A variable holding only spaces
168
+ is a value, while an environment name holding only spaces is refused. A name made of
169
+ spaces can mean nothing, and a value made of spaces can; the asymmetry is deliberate
170
+ and easy to trip over.
171
+
172
+ **A value reaches the consumer verbatim.** A declaration is substituted after the
173
+ document has been parsed, so a value cannot add keys, define anchors or overwrite a
174
+ neighbour the way it could through a template tag. In exchange the parser no longer
175
+ folds control characters: what the source holds arrives as it is, which matters for
176
+ values that end up in mail headers or glued into a URL.