@onlooker-community/ecosystem 0.34.0 → 0.43.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.
Files changed (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,317 @@
1
+ # Author Key Derivation — Design
2
+
3
+ **Status:** Shipped.
4
+ **Tracked by:** `ecosystem-4z8.5`, under epic `ecosystem-4z8`.
5
+ **Blocks:** `ecosystem-4z8.4` (approved pool and declined ledger).
6
+ **Contract:** `docs/lesson-promotion-pipeline.md:92`.
7
+
8
+ ---
9
+
10
+ ## What this is
11
+
12
+ The last thing standing between a judged lesson and the pool. A lesson cannot be
13
+ written without an `author_key`, and nothing derives one today — `grep
14
+ author_key` across this repo returns documentation and nothing else.
15
+
16
+ The contract pins the format. The derivation lives on the producing side, which
17
+ is us.
18
+
19
+ ## The property that matters
20
+
21
+ `author_key` is derived **per visibility scope**, so a user's `org` identity and
22
+ their `public` identity cannot be linked by anyone who does not hold their
23
+ secret.
24
+
25
+ The failure mode is what makes this its own stage rather than a decision made
26
+ inline: **getting the derivation wrong breaks unlinkability silently.** Nothing
27
+ throws. No test goes red on its own. The pool fills with keys that look
28
+ perfectly well-formed and quietly leak the association they exist to prevent.
29
+ Every decision below is shaped by that.
30
+
31
+ ## The secret
32
+
33
+ `$ONLOOKER_DIR/author/user_secret` — a plain file created `0600` on first
34
+ derivation, holding the output of `openssl rand -hex 32`.
35
+
36
+ Be precise about the width, because this is the same unit confusion that makes
37
+ the `author_key` field ambiguous: `-hex 32` requests **32 bytes** and prints
38
+ them as **64 hex characters**. The file therefore holds a 64-character string.
39
+ Do not "correct" it to `-hex 16` on the assumption that the count refers to
40
+ characters — that would halve the secret's entropy, and nothing would fail.
41
+
42
+ ### Not `$RANDOM`
43
+
44
+ Archivist's ULID helper builds randomness from `$RANDOM`
45
+ (`plugins/archivist/scripts/lib/archivist-ulid.sh:41-44`). That is correct for a
46
+ sortable identifier and disqualifying for a secret — `$RANDOM` is a 15-bit
47
+ PRNG seeded predictably.
48
+
49
+ Assayer already uses `openssl rand -hex`
50
+ (`plugins/assayer/scripts/lib/assayer-ulid.sh:29`), which is the pattern to
51
+ copy. This is called out because **the wrong example is the closer one**: an
52
+ implementer looking for "how does this repo generate randomness" is more likely
53
+ to land on the ULID helpers.
54
+
55
+ ### Never regenerated when it exists
56
+
57
+ A missing file means first use. An existing file is authoritative.
58
+
59
+ This is load-bearing. Regenerating silently changes the user's identity, which
60
+ orphans every lesson they have written — including their ability to retract
61
+ any of them, since retraction is authorized by holding the key that produced
62
+ them. A regeneration bug would not surface as an error; it would surface as a
63
+ person's history quietly ceasing to be theirs.
64
+
65
+ ### Portable by construction
66
+
67
+ It is a plain file so that copying it works. The documented instruction is to
68
+ copy it to the second machine **before first use there**, because a machine
69
+ that has already generated a secret will not overwrite it.
70
+
71
+ The cost of portability is stated rather than hidden: the user now holds a
72
+ secret they can lose or leak. Losing it means a new identity. Leaking it means
73
+ someone can forge lessons as them and link their scoped identities.
74
+
75
+ ### Permissions
76
+
77
+ Created `0600`. On read, if the mode is wider, tighten it to `0600` and warn on
78
+ stderr — do not refuse.
79
+
80
+ Refusing would block the promotion path over a condition the user cannot fix
81
+ without guidance, and tightening silently would hide a real exposure. Note that
82
+ tightening does not undo whatever exposure already occurred; it stops the next
83
+ one. The warning is the part that matters.
84
+
85
+ ## The derivation
86
+
87
+ ```
88
+ author_key(visibility) =
89
+ HMAC-SHA256(key = user_secret, message = "onlooker.author.v1:" + visibility)
90
+ truncated to its first 16 bytes
91
+ rendered as 32 lowercase hex
92
+ ```
93
+
94
+ Verified shape on LibreSSL 3.3.6, and the algorithm it verifies is unchanged
95
+ by the implementation note below:
96
+
97
+ ```bash
98
+ printf '%s' "onlooker.author.v1:${visibility}" \
99
+ | openssl dgst -sha256 -hmac "$secret" -r \
100
+ | cut -d' ' -f1 \
101
+ | cut -c1-32
102
+ ```
103
+
104
+ ### Implementation: node, not openssl, for the HMAC call
105
+
106
+ The shape above is `openssl`, because that's what proves the algorithm on the
107
+ command line. The shipped implementation calls `node` instead, for one reason:
108
+ `openssl dgst -sha256 -hmac` takes the key as a CLI argument, and there is no
109
+ flag to take it any other way. On Linux, `/proc/<pid>/cmdline` is
110
+ world-readable, so for the life of every derivation call, any local user could
111
+ read `$secret` straight out of the process table. `node`'s `crypto.createHmac`
112
+ takes the key from a variable, so the secret goes over the **environment**
113
+ instead (`/proc/<pid>/environ` is owner-only) and never appears on argv;
114
+ `visibility` still does, because it isn't secret.
115
+
116
+ The algorithm did not change: node's `crypto.createHmac("sha256", secret)`
117
+ computes the identical HMAC-SHA256 as `openssl dgst -sha256 -hmac`, verified
118
+ against all three golden vectors byte-for-byte before the switch shipped. The
119
+ node call still emits the full 64 hex characters; truncation to 32 stays a
120
+ separate step in the shell, gated by the digest-width sanity check described
121
+ below — that check, not the command substitution's own exit status, is what
122
+ makes a misbehaving subprocess fail closed, since there is no `pipefail` on a
123
+ pipeline that no longer exists once `cut` is gone.
124
+
125
+ The repo already depends on `node` (see `librarian-emit.sh` and its
126
+ `curator`/`historian` counterparts), so this adds no new dependency. `node`
127
+ absence is handled the same way `openssl` absence was: refuse, with a reason
128
+ on stderr, before attempting the call.
129
+
130
+ ### Why a domain tag and a version
131
+
132
+ Once lessons exist in a shared pool, **the derivation is permanent**. Changing
133
+ it gives every user a new `author_key` and orphans everything they wrote.
134
+
135
+ `onlooker.author.v1:` buys two things for the cost of one string constant. The
136
+ domain tag stops this secret's output from colliding with any other use of the
137
+ same secret. The version lets a future `v2` add an org identity — so a user in
138
+ two orgs gets two keys — without ambiguity: `v1` lessons keep validating under
139
+ `v1`, new ones move to `v2`, and the two are distinguishable rather than
140
+ silently conflated.
141
+
142
+ **An org identity is deliberately not in `v1`.** No org id exists anywhere in
143
+ this system. Feeding one in now would invent structure for an unwritten
144
+ consumer, which is the mistake `ecosystem-si6` avoided by refusing to add a
145
+ `judging` status for a jury that did not exist.
146
+
147
+ ### Truncation
148
+
149
+ **Verified against the contract.** `ZAuthorKey` in
150
+ `onlooker/packages/lesson-contract/src/primitives.ts:35-41` is
151
+ `z.string().regex(/^[0-9a-f]{32}$/)` — 32 characters, confirming the literal
152
+ reading. Its own comment reasons the same way we did: "128 bits makes that
153
+ negligible; truncating further buys nothing."
154
+
155
+ HMAC-SHA256 natively produces 64 hex characters, so the derivation truncates
156
+ to the first 16 bytes.
157
+
158
+ The contract documents the field as `HMAC(user_secret, scope)` but **does not
159
+ pin what `scope` is**, so the producing side owns that choice and the
160
+ `onlooker.author.v1:` domain tag below is compatible with it.
161
+
162
+ That comment also states what `author_key` is *for*, which sharpens why
163
+ stability matters: it "is what org revocation and public blocking act on." A
164
+ regenerated secret does not merely orphan a user's lessons — it walks them out
165
+ from under a block.
166
+
167
+ ### The empty-secret trap
168
+
169
+ An empty or truncated secret file HMACs perfectly happily. `HMAC("", scope)` is
170
+ **identical for every user in that state** — a corrupt secret does not fail, it
171
+ silently collapses everyone onto one shared identity, which is the precise
172
+ opposite of what this stage exists to guarantee.
173
+
174
+ The derivation therefore refuses when the secret is missing, empty, or shorter
175
+ than expected, rather than proceeding with whatever it read. **"Shorter than
176
+ expected" means fewer than 64 hex characters** — the full width `openssl rand
177
+ -hex 32` produces. A short-but-nonempty secret is the harder case: it still
178
+ derives a plausible-looking key, just one with less entropy behind it than the
179
+ design claims.
180
+
181
+ **"Shorter than expected" has a mirror: longer than expected, and it is
182
+ refused too, not accepted.** The width check is exact — `^[0-9a-f]{64}$`,
183
+ anchored on both ends — not "64 or more." HMAC does not ignore extra key
184
+ width; a 65-character value is a *different* secret, not a wider version of
185
+ the same one. Only the raw **first line** of the file is read and validated,
186
+ before any newline stripping. That single choice closes both the
187
+ malformed-content case (64 characters of the wrong shape — uppercase, spaces,
188
+ punctuation — pass a length check but not the anchored hex check, and are
189
+ refused as malformed rather than deriving a garbage-but-deterministic
190
+ identity) and an embedded-newline case: the documented way to move the secret
191
+ to a second machine is a plain copy, but a user who instead appends (`>>`
192
+ instead of `>`, or a backup restored on top of an existing file) leaves a
193
+ second 64-hex line sitting after the first. Stripping newlines from the whole
194
+ file before validating would concatenate the two lines into a 128-character
195
+ string that reads as one long-but-"valid" secret — a *third* identity,
196
+ matching neither machine, accepted silently. Reading only the first line
197
+ means content past it is simply never read, so that case can't arise.
198
+
199
+ ### Permissions guard: the secret path must be a regular file
200
+
201
+ `ln FILE DIR` succeeds — POSIX `ln` links `basename(FILE)` *inside* an
202
+ existing directory rather than failing — so a directory sitting at the secret
203
+ path used to make first-use creation look like it succeeded (`created=1`)
204
+ while stranding a fresh 0600 secret one level down, at
205
+ `$path/user_secret.XXXXXX`, that nothing ever reads again: one new stray
206
+ secret file per call. A pre-existing FIFO at the secret path is worse: `ln`
207
+ correctly refuses to overwrite it, but the subsequent plain `cat` used to
208
+ block forever with no writer, hanging the calling session — a hard violation
209
+ of this repo's "a plugin must never block a session" constraint.
210
+
211
+ Both are the same underlying condition: something other than a regular file
212
+ at the secret path. The derivation checks for it explicitly (`[[ -f "$path"
213
+ ]]`) immediately after the creation step and before either the permission
214
+ tightening step or the read below can touch it, cleans up any stray secret
215
+ material the `ln`-into-directory case may have left, and refuses with a
216
+ reason naming the real cause.
217
+
218
+ ## Interface
219
+
220
+ `librarian_author_key <visibility>`, in
221
+ `plugins/librarian/scripts/lib/librarian-author-key.sh`.
222
+
223
+ Echoes 32 lowercase hex and returns 0. On failure, returns non-zero and writes
224
+ **nothing to stdout**, with a reason on stderr.
225
+
226
+ All three visibilities get a key, `private` included. Private lessons never
227
+ leave the machine, but the contract requires the field on every lesson, and
228
+ deriving uniformly means `4z8.4` carries no special case.
229
+
230
+ It refuses, rather than improvising, when `openssl` (secret creation) or
231
+ `node` (HMAC derivation) is absent, the secret cannot be created or read, the
232
+ secret path is not a regular file, the secret is empty, short, too long, or
233
+ malformed, or the visibility is not one of `private` / `org` / `public`.
234
+ Every refusal writes a reason — this is the one place in the pipeline where
235
+ silence is the actual danger.
236
+
237
+ ### What a caller does with a failure
238
+
239
+ The same shape the jury established: `4z8.4` leaves the lesson `approved`,
240
+ writes nothing to the pool, and reports it. **"Could not derive" is not
241
+ "declined"**, for the same reason "could not judge" is not "rejected."
242
+
243
+ The fail-soft convention still holds at the session boundary — nothing here
244
+ blocks a session. Within the promotion path, refusing to write is correct: a
245
+ pool entry carrying a wrong or shared `author_key` is worse than no entry.
246
+
247
+ ## Testing
248
+
249
+ State the limit first: **no test proves unlinkability.** That rests on HMAC's
250
+ properties and on the secret staying secret. Tests catch derivation drift and
251
+ gross errors. The suite is therefore built around one idea — make any change to
252
+ the algorithm turn something red.
253
+
254
+ bats, isolated temp home, per the repo's `writing-tests` skill — single-bracket
255
+ assertions or `|| return 1` on non-final ones, and every new assertion broken
256
+ once to confirm it discriminates.
257
+
258
+ - **A golden vector.** A fixed secret and fixed visibility produce a hardcoded
259
+ expected 32-hex string. This is the load-bearing test: change the domain tag,
260
+ the truncation width, the hash, or the argument order and it goes red. It is
261
+ what makes "the derivation is permanent" enforceable rather than aspirational.
262
+ - **Determinism** — same inputs twice, identical output. Retraction depends on it.
263
+ - **Scope separation** — `private`, `org`, and `public` from one secret produce
264
+ three distinct keys.
265
+ - **Secret separation** — two different secrets at the same visibility produce
266
+ different keys. Catches a constant that ignores the secret entirely, which
267
+ scope separation alone would not.
268
+ - **Idempotent creation** — derive, snapshot the secret file, derive again,
269
+ assert the file is byte-identical. Catches silent regeneration.
270
+ - **An empty secret is refused** — a zero-byte secret file yields non-zero and
271
+ empty stdout. This is the guard against everyone collapsing onto one identity.
272
+ - **A short secret is refused**, naming the problem distinctly from empty.
273
+ - **A malformed secret is refused** — 64 characters of the wrong shape (not
274
+ `[0-9a-f]`) clears the length check but is still rejected, distinguishably
275
+ from both empty and short. Length is not content.
276
+ - **A too-long secret is refused, not accepted as a wider key** — 65
277
+ characters of otherwise-valid hex. Pins the width check as exact
278
+ (`{64}`, anchored), not "64 or more": the regression this guards against
279
+ is a real one that shipped and was caught in review, not a hypothetical.
280
+ - **A second line in the secret file changes nothing** — two concatenated
281
+ 64-hex lines (the `>>`-instead-of-`>` shape) still derive the golden
282
+ vector for the first line alone, proving the second line is never read
283
+ rather than silently concatenated into a third identity.
284
+ - **Format** — exactly 32 characters, all `[0-9a-f]`. Catches uppercase and
285
+ width drift.
286
+ - **The key is not the secret** — catches a "derivation" that echoes its input.
287
+ - **No `$RANDOM` in the lib**, asserted by grep, because the wrong pattern is
288
+ the nearer example in this repo.
289
+ - **Permissions** — created `0600`; a `0644` file is tightened and warned about.
290
+ - **An ACL-only grant is warned about** — `chmod +a` on macOS produces a
291
+ `-rw-------+` mode string that looks clean; the warning fires anyway
292
+ because the ACL flag is checked separately from the mode bits. Darwin-only,
293
+ skipped on Linux CI where the fixture can't be constructed the same way.
294
+ - **The secret never reaches the HMAC subprocess over argv** — a spy `node`
295
+ on `PATH` records its own argv and delegates to the real `node`, so the
296
+ derivation still has to produce the golden vector while proving the
297
+ secret isn't sitting in the process table.
298
+ - **A directory at the secret path is refused**, not silently stranded with
299
+ a fresh secret one level down that nothing reads again.
300
+ - **A FIFO at the secret path is refused rather than hanging** the calling
301
+ session forever. Run under `timeout` as a safety net for the test itself.
302
+ - **The `created` guard is pinned directly** — a stub `mktemp` on `PATH`
303
+ simulates a creation-path regression (a freshly created secret landing at
304
+ `0644` instead of `0600`) and asserts the weak permissions survive
305
+ un-repaired on that same call, which is what makes the regression
306
+ observable to a human or to the permissions test above instead of being
307
+ silently papered over.
308
+ - **The returned key carries no trailing newline** — `run`'s `$output` and
309
+ `$()` both strip trailing newlines, so this needs a sentinel appended
310
+ immediately after the call to make a stray one visible.
311
+
312
+ ## Out of scope
313
+
314
+ The approved pool and declined ledger (`4z8.4`), any sync, and key rotation or
315
+ revocation — there is no consumer for either, and inventing one now repeats the
316
+ mistake `si6` avoided. Also out of scope: an org identity in the HMAC input,
317
+ which is what `v2` exists for.
@@ -0,0 +1,232 @@
1
+ # Approved Pool and Declined Ledger — Design
2
+
3
+ **Status:** Approved, not started.
4
+ **Tracked by:** `ecosystem-4z8.4`, the last open child of epic `ecosystem-4z8`.
5
+ **Upstream:** the jury (`4z8.3`, PR #142) and `author_key` derivation (`4z8.5`, PR #144).
6
+
7
+ ---
8
+
9
+ ## What this stage is
10
+
11
+ The terminal state. A judged proposal becomes either a pool entry awaiting sync
12
+ or a row in the declined ledger, and the pipeline is done with it.
13
+
14
+ Nothing crosses the network. The sync service that drains the approved pool
15
+ does not exist yet.
16
+
17
+ ## The verb
18
+
19
+ `librarian_lesson_promote <key> <lesson_id>`.
20
+
21
+ | Proposal status | Result |
22
+ |---|---|
23
+ | `approved` | a pool entry at `lessons/approved/<ulid>.json` |
24
+ | `rejected` | a row appended to `lessons/declined.jsonl` |
25
+ | `confirmed` | refused — judged nothing yet |
26
+ | `pending`, `passed` | refused, naming the status |
27
+ | already promoted | no-op success |
28
+
29
+ The `lessons judge` CLI verb calls it immediately after recording a verdict, so
30
+ the ordinary path stays one command. It is also runnable standalone, which is
31
+ not redundancy: **promotion fails for reasons judging does not.** A malformed
32
+ `user_secret`, an absent `node`, a full disk — none of those are the jury's
33
+ problem, and all of them leave a lesson correctly judged and not yet promoted.
34
+ Standalone promote is how that gets reconciled.
35
+
36
+ ### Already promoted is a no-op, not an error
37
+
38
+ Same precedent as `unconfirm` on a `pending` lesson. It makes a reconcile loop
39
+ trivially safe to run over everything. "The same artifact cannot be promoted
40
+ twice" is still enforced — there is no second write and `promoted_at` does not
41
+ change — it simply is not an error to ask.
42
+
43
+ ## The pool entry
44
+
45
+ `ZLesson`-shaped. Its exact key set, which the design must match because
46
+ `ZLesson` is a `z.strictObject` — an extra key fails ingest as surely as a
47
+ missing one:
48
+
49
+ ```
50
+ applies_to author_key claim consensus evidence id promoted_at
51
+ rationale schema_version source status superseded_by visibility
52
+ ```
53
+
54
+ Mapped from the proposal:
55
+
56
+ | Field | Source |
57
+ |---|---|
58
+ | `id` | the proposal's ULID — the lesson keeps one identity end to end |
59
+ | `schema_version` | literal `2` |
60
+ | `claim`, `rationale`, `evidence`, `applies_to` | the candidate, unchanged |
61
+ | `visibility` | the proposal |
62
+ | `source` | `private` → **`local`**, `org` → `org`, `public` → `public` |
63
+ | `consensus` | `{judges: (verdict.judges \| length), agreed: (count where .passed), decided_at: judged_at}` |
64
+ | `status` | `active` |
65
+ | `superseded_by` | `null` |
66
+ | `author_key` | `librarian_author_key <visibility>` |
67
+ | `promoted_at` | now, ISO-8601 UTC |
68
+
69
+ ### The `source` mapping is a real mismatch, not a rename
70
+
71
+ `ZSource` is `z.enum(["local", "org", "public"])` while visibility is
72
+ `private | org | public`. Nothing in the contract or the pipeline docs states
73
+ how they line up. `private` → `local` is the only coherent reading — the tier
74
+ that never leaves the machine maps to the source that means "not from anywhere
75
+ else" — but it is written down here because an implementer would otherwise
76
+ either guess or emit `private` and fail ingest.
77
+
78
+ ### A private pool entry is deliberately not ingest-valid
79
+
80
+ `ZConsensus` requires `judges: z.number().int().min(1)`. A `private` lesson
81
+ runs no jury at all, so its `consensus.judges` is `0` and it **cannot validate
82
+ against `ZLesson`**.
83
+
84
+ This is accepted rather than worked around. The contract's own comment says
85
+ validation happens at ingest, and a private lesson never syncs — so it never
86
+ reaches the validator. The pool means "everything approved locally"; the sync
87
+ service filters to `org` and `public`.
88
+
89
+ The alternative was synthesizing `judges: 1, agreed: 1` so every entry
90
+ validates uniformly. Rejected: it fabricates a jury that never sat, and
91
+ anything later reading `consensus` to mean "this was judged" would be quietly
92
+ wrong about exactly the tier where nothing was.
93
+
94
+ **The consequence to state plainly:** there can be no blanket "validate the
95
+ pool" check. A validator must filter to `org` and `public` first, and this
96
+ spec is where that requirement is recorded.
97
+
98
+ ### The pool write is atomic
99
+
100
+ Temp file, then `mv`. `ecosystem-a3b` is open against the three existing
101
+ non-atomic write sites in this plugin (`confirm`, `pass`, `unconfirm`, all
102
+ `printf > path`, which truncates before writing). This is a new write site;
103
+ adding a fourth instance of a known bug is a choice, not an inheritance.
104
+
105
+ Fixing the existing three is `a3b`'s job and stays out of this branch.
106
+
107
+ ## The declined ledger
108
+
109
+ Jury rejections append through the existing
110
+ `librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]`,
111
+ which already produces `{artifact_id, reason, detail, declined_at}`.
112
+
113
+ The `reason` is the gate's own — `below_threshold`, `jury_not_unanimous`,
114
+ `jury_not_majority`. The pool entry's `consensus` keeps only counts, so for a
115
+ rejected lesson this ledger is the only place the per-judge scores and feedback
116
+ survive, and that is the rubric-tuning signal.
117
+
118
+ **The verdict needs its own key, not `detail`.** The existing function builds
119
+ `detail` with `jq --arg`, which makes it a *string* — passing the verdict there
120
+ would store serialized JSON inside a JSON field, and every consumer would have
121
+ to `fromjson` it before reading a score. Instead, extend
122
+ `librarian_lesson_append_declined` with an optional fifth argument emitted via
123
+ `--argjson` into its own `verdict` key, so it lands as a real nested object.
124
+
125
+ Rows written by stage 5 simply lack that key, which is correct: a transform
126
+ failure has no verdict. `librarian_lesson_seen` reads only `.artifact_id`, so
127
+ the added key does not touch it.
128
+
129
+ ### Why jury rejections belong in this file rather than a third one
130
+
131
+ `4z8.2` drew the line between `declined.jsonl` and `passed.jsonl` at **machine
132
+ verdict versus human intent** — mixing "I didn't want to share this" into the
133
+ same file as "the model emitted malformed JSON" would corrupt the signal the
134
+ file exists to carry.
135
+
136
+ A jury rejection is a machine quality judgment. By that line it belongs in
137
+ `declined.jsonl` alongside the transform's own drops, distinguished by `reason`.
138
+
139
+ **This does *not* leave `librarian_lesson_seen` untouched, as shipped.** It
140
+ already reads `declined.jsonl` and `proposals/`, and also scans `approved/` —
141
+ but that scan can never match a promoted entry, because `ZLesson` is a
142
+ `strictObject` with no `artifact_id` field (see the pool entry's key set,
143
+ above). Dedup for a promoted lesson works today only because `proposals/` is
144
+ never pruned, standing in for coverage the pool entry cannot provide. That gap
145
+ is tracked as `ecosystem-d0m`; this stage does not close it.
146
+
147
+ ## Ordering, and why it is load-bearing
148
+
149
+ The terminal record lands **first**. Only then is `promoted_at` stamped on the
150
+ proposal.
151
+
152
+ Reversed, a stamp followed by a failed write would leave the lesson permanently
153
+ unpromotable — marked done, present nowhere, and invisible to any reconcile
154
+ that keys on the absence of the stamp. In this order a failure between the two
155
+ steps means a re-run finds the pool entry already present, skips rewriting it,
156
+ and completes the stamp.
157
+
158
+ Every failure path before the terminal record lands writes **nothing** and
159
+ returns non-zero with a reason on stderr. One failure path is the exception by
160
+ design: the terminal record can land and then the stamp itself fail (a
161
+ read-only `proposals/`, an interrupted process). There the record is on disk,
162
+ the proposal stays `approved` without `promoted_at`, and the stderr message
163
+ says so explicitly — naming the standalone re-run — so this state is never
164
+ mistaken for "nothing written."
165
+
166
+ ## The proposal file survives
167
+
168
+ It stays in `proposals/` carrying its terminal status, `judged_at`, `verdict`,
169
+ and now `promoted_at`.
170
+
171
+ Two reasons. The `promoted_at` stamp is what makes promote-twice detectable at
172
+ all. And the pool entry carries only `consensus` counts, so for an **approved**
173
+ lesson the per-judge scores and feedback exist nowhere else — deleting the
174
+ proposal would discard the rubric-tuning signal for every lesson that passed,
175
+ keeping it only for those that failed.
176
+
177
+ `proposals/` therefore grows without bound. Accepted: `lessons list` already
178
+ filters to `pending`, and `librarian_lesson_seen` scanning a larger directory is
179
+ the same work it already does.
180
+
181
+ ## Events
182
+
183
+ None. `@onlooker-community/schema` 2.11.0 registers only
184
+ `meridian.lesson.curated` — no `librarian.lesson.*` at all — and the runtime
185
+ emitter exits 1 on an unknown `event_type`.
186
+
187
+ This is the fourth consecutive stage running silent for the same reason. When
188
+ the schema package publishes the types, one follow-up wires all four together.
189
+
190
+ ## Testing
191
+
192
+ bats, isolated temp home, per the repo's `writing-tests` skill — single-bracket
193
+ assertions or `|| return 1` on non-final ones, and every new assertion broken
194
+ once to confirm it discriminates.
195
+
196
+ - **The pool entry's key set exactly equals `ZLesson`'s.** `ZLesson` is a
197
+ `strictObject`, so an extra key fails ingest as surely as a missing one, and
198
+ this is checkable without running zod: compare `jq 'keys_unsorted | sort'`
199
+ against the literal list above.
200
+ - `private` yields `consensus.judges: 0` and `source: local`; `org` and
201
+ `public` yield matching `source` and `judges`/`agreed` derived from the
202
+ verdict's array.
203
+ - `agreed <= judges` — the contract's own ingest rule, which its schema
204
+ deliberately cannot express.
205
+ - A `rejected` proposal writes a `declined.jsonl` row carrying the gate's reason
206
+ and the verdict, **and no pool entry**. The row's `verdict` is a nested
207
+ object, not a string — assert `jq '.verdict.judges | length'` rather than a
208
+ substring match, since a `--arg`/`--argjson` mistake produces a row that looks
209
+ right to the eye and is unusable to a consumer.
210
+ - A stage-5 style decline (transform failure, no verdict) still writes cleanly
211
+ and simply lacks the `verdict` key.
212
+ - `confirmed`, `pending`, and `passed` are each refused, naming the status.
213
+ - Promoting twice leaves exactly one pool entry with an unchanged
214
+ `promoted_at`.
215
+ - **A failing `author_key` leaves nothing written and the proposal still
216
+ `approved` without `promoted_at`** — the reconcile property, and the one most
217
+ worth pinning.
218
+ - `librarian_lesson_seen` reports the artifact handled after the declined
219
+ path. There is deliberately no approved-path equivalent: a promoted entry
220
+ carries no `artifact_id`, so that assertion would pass or fail on whether
221
+ `proposals/` still holds the file, never on anything promote itself wrote —
222
+ see `ecosystem-d0m`.
223
+ - **Promotion spends nothing** — asserted with a `claude` stub on `PATH` that
224
+ fails loudly if invoked, the technique that already proved stages 5, 6, and 7.
225
+
226
+ ## Out of scope
227
+
228
+ Any sync. Retraction and supersession — `status` is always `active` and
229
+ `superseded_by` always `null`, because there is no consumer for either and
230
+ inventing one repeats the mistake `si6` avoided. Events, per above. Consuming
231
+ the declined ledger for rubric tuning. And fixing `ecosystem-a3b`'s three
232
+ existing non-atomic writes.