@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.
- package/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- 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.
|