create-pmos 0.134.0 → 0.135.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 (209) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +16 -1
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-capture.js +9 -1
  83. package/workspace/lib/notes-final.js +38 -13
  84. package/workspace/lib/notes-replay.js +125 -0
  85. package/workspace/lib/notes-screen.js +180 -0
  86. package/workspace/lib/notes-whisper.js +10 -3
  87. package/workspace/lib/outbox-store.js +10 -6
  88. package/workspace/lib/pmos-verbs.js +3 -1
  89. package/workspace/lib/proactive-cli.js +7 -0
  90. package/workspace/lib/proactive.js +15 -2
  91. package/workspace/lib/repo-script.js +36 -0
  92. package/workspace/lib/report-dashboards.js +5 -2
  93. package/workspace/lib/report-frame.js +122 -0
  94. package/workspace/lib/report-schedules.js +113 -0
  95. package/workspace/lib/route-modules.js +9 -2
  96. package/workspace/lib/routes-boards.js +734 -0
  97. package/workspace/lib/routes-brief-page.js +13 -4
  98. package/workspace/lib/routes-briefing.js +22 -11
  99. package/workspace/lib/routes-c3-init.js +4 -3
  100. package/workspace/lib/routes-c3-life.js +2 -1
  101. package/workspace/lib/routes-c3-reports.js +96 -13
  102. package/workspace/lib/routes-c3-strategy.js +4 -3
  103. package/workspace/lib/routes-c3-timeline.js +3 -2
  104. package/workspace/lib/routes-calendar-import.js +3 -2
  105. package/workspace/lib/routes-calendar-views.js +9 -4
  106. package/workspace/lib/routes-doc-health.js +2 -1
  107. package/workspace/lib/routes-doc-sheet.js +2 -1
  108. package/workspace/lib/routes-forecast.js +2 -1
  109. package/workspace/lib/routes-hooks.js +3 -2
  110. package/workspace/lib/routes-initiatives.js +2 -1
  111. package/workspace/lib/routes-intake.js +2 -1
  112. package/workspace/lib/routes-notes.js +55 -9
  113. package/workspace/lib/routes-outbox.js +23 -3
  114. package/workspace/lib/routes-pm-items.js +19 -6
  115. package/workspace/lib/routes-records.js +3 -2
  116. package/workspace/lib/routes-releases.js +2 -1
  117. package/workspace/lib/routes-report-facts.js +4 -3
  118. package/workspace/lib/routes-report-families.js +2 -1
  119. package/workspace/lib/routes-roadmap-ops.js +5 -4
  120. package/workspace/lib/routes-runs.js +6 -5
  121. package/workspace/lib/routes-settings.js +51 -0
  122. package/workspace/lib/routes-signals.js +3 -2
  123. package/workspace/lib/routes-stage-tools.js +220 -0
  124. package/workspace/lib/run-answers.js +2 -1
  125. package/workspace/lib/settings-store.js +226 -0
  126. package/workspace/lib/stage-tools.js +208 -0
  127. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  128. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  129. package/workspace/lib/work-evidence.js +73 -0
  130. package/workspace/package-lock.json +2 -2
  131. package/workspace/package.json +1 -1
  132. package/workspace/public/pm/briefing-deck.js +63 -17
  133. package/workspace/public/pm/briefing-model.js +36 -7
  134. package/workspace/public/pm/c3-ambient.js +100 -0
  135. package/workspace/public/pm/c3-brief-needs.js +26 -6
  136. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  137. package/workspace/public/pm/c3-brief-today.js +56 -21
  138. package/workspace/public/pm/c3-brief.js +2 -1
  139. package/workspace/public/pm/c3-calendar.js +161 -42
  140. package/workspace/public/pm/c3-calpop.js +5 -5
  141. package/workspace/public/pm/c3-demo-boards.js +649 -0
  142. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  143. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  144. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  145. package/workspace/public/pm/c3-demo-data.js +17 -27
  146. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  147. package/workspace/public/pm/c3-demo.js +2 -1
  148. package/workspace/public/pm/c3-dock.js +1 -1
  149. package/workspace/public/pm/c3-ib-docs.js +80 -0
  150. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  151. package/workspace/public/pm/c3-ib-items.js +201 -0
  152. package/workspace/public/pm/c3-ib-releases.js +51 -0
  153. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  154. package/workspace/public/pm/c3-ib-triage.js +178 -0
  155. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  156. package/workspace/public/pm/c3-ini-records.js +197 -0
  157. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  158. package/workspace/public/pm/c3-ini-ui.js +229 -0
  159. package/workspace/public/pm/c3-ini-work.js +139 -0
  160. package/workspace/public/pm/c3-initiatives.css +218 -0
  161. package/workspace/public/pm/c3-initiatives.js +741 -0
  162. package/workspace/public/pm/c3-plan.js +2 -2
  163. package/workspace/public/pm/c3-reports.css +47 -47
  164. package/workspace/public/pm/c3-reports.js +223 -185
  165. package/workspace/public/pm/c3-settings.css +74 -0
  166. package/workspace/public/pm/c3-settings.js +703 -0
  167. package/workspace/public/pm/c3-shell.js +102 -36
  168. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  169. package/workspace/public/pm/c3-terminal.js +2 -2
  170. package/workspace/public/pm/calendar-views.js +1 -1
  171. package/workspace/public/pm/components.css +319 -213
  172. package/workspace/public/pm/console3.css +99 -23
  173. package/workspace/public/pm/dest-notes.js +26 -4
  174. package/workspace/public/pm/notes-screen-capture.js +165 -0
  175. package/workspace/public/pm/notes-screen-view.js +98 -0
  176. package/workspace/public/pm/panel-trees.js +5 -2
  177. package/workspace/public/pm/pm-work-view.js +233 -146
  178. package/workspace/public/pm/shell-bell.js +3 -2
  179. package/workspace/public/pm/shell-recording.js +29 -8
  180. package/workspace/public/pm/tokens.css +2 -2
  181. package/workspace/public/pm/view-notes.js +46 -9
  182. package/workspace/public/pm/welcome.js +197 -89
  183. package/workspace/public/welcome.html +6 -4
  184. package/workspace/server.js +105 -6
  185. package/workspace/skills/day-check.skill +1 -1
  186. package/workspace/skills/jira-sync.skill +6 -4
  187. package/workspace/skills/morning-brief.skill +1 -1
  188. package/workspace/test-files.json +12 -3
  189. package/workspace/test-lists/c3-ib-items.json +9 -0
  190. package/workspace/test-lists/c3-ib-more.json +9 -0
  191. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  192. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  193. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  194. package/workspace/test-lists/console3-briefing.json +9 -0
  195. package/workspace/test-lists/console3-foundation.json +9 -0
  196. package/workspace/test-lists/console3-reports.json +3 -3
  197. package/workspace/test-lists/meeting-notes.json +10 -4
  198. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  199. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  200. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  201. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  202. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  203. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  204. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  205. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  206. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  207. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  208. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  209. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -1,321 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
- /* sink.js — the ONE place the append-only ledger discipline lives.
4
- *
5
- * ── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────
6
- * `record-eval.js` was PMOS's first validated writer. Three independent passes found four defects in
7
- * it, and all four were fixed IN THAT FILE:
8
- *
9
- * 1. the sink was a caller-supplied allowlist -> a caller could name its own destination
10
- * 2. the file was opened before validation -> N refused writes leaked N descriptors to EMFILE
11
- * 3. a refused write still created the sink -> refusing to write left the file behind
12
- * 4. the symlink check was a path-level stat -> a hardlink walked straight through it
13
- *
14
- * Two writers built afterwards inherited the fixes BY HAND, and each inheritance read as evidence
15
- * that the pattern was holding. It was not holding — it was propagating FORWARD ONLY. The two
16
- * writers that already existed when the defects were found were never revisited, because the process
17
- * asks "does the new thing follow the rule?" and never "where else does this rule already live?"
18
- *
19
- * Measured by attempted write on 2026-08-04:
20
- *
21
- * scripts/work-item.js -> wrote 198 bytes through a symlink into a decoy
22
- * scripts/verify-queue.js -> the same, AND wrote 202 bytes into a fresh tmpdir, because its
23
- * allowlist compared `path.basename` alone. A basename is not a
24
- * location; every directory on disk has one.
25
- *
26
- * Both are merged, PM-accepted, and load-bearing for D65 steps 1 and 2. So the discipline moves here
27
- * and the writers call it. One wrong place that every assertion points at beats five where three
28
- * happen to be right.
29
- *
30
- * ── THE RULE, STATED ONCE ───────────────────────────────────────────────────────────────────────
31
- * Resolve before allowing. Validate before opening. Write to the descriptor that was vetted.
32
- *
33
- * The third clause is the one that looks redundant and is not. A path checked and then re-opened by
34
- * path is two operations on two objects: whatever was approved may not be what gets written. The fd
35
- * IS the approval, so it is what the caller must be handed.
36
- */
37
-
38
- const fs = require('fs');
39
- const path = require('path');
40
-
41
- /**
42
- * THE `ForbiddenWrite`. One class, exported, re-exported by every writer that uses this guard.
43
- *
44
- * There were five of these — one per writer that had a guard at all — and that is not a cosmetic duplication. Callers and
45
- * assertions tell "refused on purpose" from "crashed" with `instanceof`, so a guard throwing its own
46
- * private copy makes every one of those checks silently false: `scripts/tests/work-item.sh` reported
47
- * `threw ForbiddenWrite, want ForbiddenWrite` on seventeen assertions the moment this module started
48
- * throwing a second class with the same name. A refusal nobody can recognise as a refusal is not one.
49
- *
50
- * `detail` is always set (null when absent) rather than conditionally attached, because a property
51
- * that sometimes exists is a second shape for callers to handle.
52
- */
53
- class ForbiddenWrite extends Error {
54
- constructor(message, detail) {
55
- super(message);
56
- this.name = 'ForbiddenWrite';
57
- this.detail = detail || null;
58
- }
59
- }
60
-
61
- /** Resolve a path through its real directory. Kept separate so the sink and the target are compared
62
- * the same way — comparing a resolved target against an unresolved sink is a mismatch by
63
- * construction, and would refuse every legitimate write on a machine where /tmp is a symlink. */
64
- function resolveThrough(p) {
65
- return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p));
66
- }
67
-
68
- /**
69
- * Build a guard bound to ONE sink.
70
- *
71
- * @param {object} spec
72
- * @param {string} spec.sink absolute path of the only file this guard will ever open.
73
- * @param {string} spec.what noun for error messages, e.g. 'work-item events'.
74
- * @returns {{ open: function, sinkPath: function, __setSinkForTests: function }}
75
- *
76
- * The sink is a CONSTRUCTION-TIME constant, never an argument to `open`. That is defect 1 of the
77
- * original four, and re-admitting it as "just a parameter" is how it would come back.
78
- */
79
- function createSinkGuard(spec) {
80
- if (!spec || typeof spec.sink !== 'string' || !spec.sink) {
81
- throw new ForbiddenWrite('a sink guard needs a declared `sink` path — refused (fail-closed)');
82
- }
83
- const what = spec.what || 'records';
84
- const declared = spec.sink;
85
- let overrideForTests = null;
86
-
87
- /* A seam, not a door. Gated so it cannot be reached in a production process even if something
88
- * calls it — the point of A4 is that no code path lets a caller choose the destination. */
89
- function __setSinkForTests(p) {
90
- /* An explicit allowed set. `!process.env.PMOS_TEST` treats the STRING '0' as truthy, so
91
- * PMOS_TEST=0 — the natural way an operator turns test mode OFF — OPENED the production sink
92
- * override instead of closing it. A flag whose "off" value means "on" is worse than no flag. */
93
- const on = (v) => v === '1' || v === 'true' || v === 'yes';
94
- if (process.env.NODE_ENV !== 'test' && !on(process.env.PMOS_TEST)) {
95
- throw new ForbiddenWrite('__setSinkForTests is a test seam — set NODE_ENV=test or PMOS_TEST=1 to use it');
96
- }
97
- overrideForTests = p;
98
- }
99
-
100
- const sinkPath = () => overrideForTests || declared;
101
-
102
- /**
103
- * Pre-flight: validate the record and confirm the target RESOLVES to the sink — without opening
104
- * anything. For callers that want to ask "would this be permitted?" without a side effect.
105
- *
106
- * NOT AUTHORITATIVE, and the gap is deliberate rather than an oversight. Whether the sink is a
107
- * symlink or carries a second hard link cannot be established without a descriptor, and a `lstat`
108
- * here would answer about a path that may differ from the file eventually opened — which is the
109
- * check-then-write race this module exists to close. `open` is the authority; this is a courtesy.
110
- * The suite asserts the difference: a symlinked sink passes `check` and is refused by `open`.
111
- */
112
- function check(targetPath, validate) {
113
- if (typeof targetPath !== 'string' || !targetPath) {
114
- throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
115
- }
116
- if (typeof validate === 'function') {
117
- const v = validate();
118
- if (!v || !v.ok) {
119
- const errs = (v && v.errors) || ['invalid'];
120
- throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
121
- }
122
- }
123
- let resolved;
124
- try { resolved = resolveThrough(targetPath); }
125
- catch { throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`); }
126
- let sinkResolved = sinkPath();
127
- try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet */ }
128
- if (resolved !== sinkResolved) {
129
- throw new ForbiddenWrite(
130
- `${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
131
- { targetPath, resolved, sink: sinkResolved });
132
- }
133
- return true;
134
- }
135
-
136
- /**
137
- * Vet `targetPath` against the declared sink and return a descriptor open for append.
138
- *
139
- * The caller MUST close it. Everything that can refuse, refuses before the file is opened, so a
140
- * refusal creates nothing and leaks nothing.
141
- *
142
- * @param {string} targetPath where the caller believes it is writing.
143
- * @param {function} [validate] returns { ok, errors } — runs BEFORE the open (defects 2 and 3).
144
- * @returns {number} a vetted file descriptor.
145
- */
146
- function open(targetPath, validate) {
147
- if (typeof targetPath !== 'string' || !targetPath) {
148
- throw new ForbiddenWrite(`no target path supplied for ${what} — refused (fail-closed)`);
149
- }
150
-
151
- /* BEFORE the open. Ordering is the whole of defects 2 and 3: validating after opening leaks a
152
- * descriptor per refusal, and O_CREAT means the refused write has already made the file. */
153
- if (typeof validate === 'function') {
154
- const v = validate();
155
- if (!v || !v.ok) {
156
- const errs = (v && v.errors) || ['invalid'];
157
- throw new ForbiddenWrite(`invalid ${what} — refused: ${errs.join('; ')}`, { errors: errs });
158
- }
159
- }
160
-
161
- let resolved;
162
- try {
163
- resolved = resolveThrough(targetPath);
164
- } catch {
165
- throw new ForbiddenWrite(`the sink directory does not resolve for '${targetPath}' — refused (fail-closed)`);
166
- }
167
- let sinkResolved = sinkPath();
168
- try { sinkResolved = resolveThrough(sinkPath()); } catch { /* not created yet — compare literally */ }
169
-
170
- /* A RESOLVED comparison, not a basename one. `verify-queue.js` compared basenames and therefore
171
- * accepted every directory on disk that happened to contain the right filename. */
172
- if (resolved !== sinkResolved) {
173
- throw new ForbiddenWrite(
174
- `${what} are written only to ${sinkResolved}; '${targetPath}' resolves to '${resolved}'`,
175
- { targetPath, resolved, sink: sinkResolved });
176
- }
177
-
178
- let fd;
179
- try {
180
- fd = fs.openSync(
181
- resolved,
182
- fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
183
- | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
184
- 0o644);
185
- } catch (e) {
186
- /* O_NOFOLLOW turns "the sink is a symlink" into ELOOP here rather than a silent write to
187
- * wherever it pointed. That is defect 4, and it is refused at open rather than by a stat that
188
- * a hardlink does not answer.
189
- *
190
- * O_NONBLOCK is not about performance. Opening a FIFO for writing BLOCKS until a reader
191
- * arrives, so a named pipe left at the sink path hangs the writer forever — found by the
192
- * assertion below, which hung this suite past its timeout instead of failing. A refusal that
193
- * never returns is worse than a wrong answer: nothing reports it and nothing times it out.
194
- * With O_NONBLOCK the same case returns ENXIO immediately and lands in the refusal path, and
195
- * on a regular file the flag does nothing at all. */
196
- throw new ForbiddenWrite(
197
- `cannot open the ${what} ledger at '${targetPath}' safely (${e.code || e.message}) — refused (fail-closed)`,
198
- { code: e.code });
199
- }
200
-
201
- try {
202
- /* fstat on the FD, not stat on the path: for the FINAL component this genuinely closes the
203
- * check-then-open race — an independent pass swapped the target inside the resolve→open window
204
- * with perfect timing and got ELOOP, nlink=2 and a refusal every time.
205
- *
206
- * It does NOT cover the PARENT directory. `resolveThrough` realpaths the directory and
207
- * `openSync` then re-walks that resolved path, so a local process able to rename the sink's
208
- * parent and drop a directory symlink in its place inside that window can redirect the append
209
- * — measured, 195 bytes into a foreign directory. That needs a concurrently-racing local
210
- * attacker with write access to the parent, a strictly larger capability than the symlink
211
- * defect this module was built to fix, so it is stated rather than claimed away. Closing it
212
- * properly needs openat-style descent, which Node does not expose. The header's "the fd IS the
213
- * approval" is true of the file, not of the path walked to reach it. */
214
- const st = fs.fstatSync(fd);
215
- if (!st.isFile()) {
216
- throw new ForbiddenWrite(`'${targetPath}' is not a regular file — refused (fail-closed)`);
217
- }
218
- if (st.nlink !== 1) {
219
- throw new ForbiddenWrite(
220
- `'${targetPath}' has ${st.nlink} hard links; a ledger with a second name is not one this ` +
221
- 'writer controls — refused (fail-closed)', { nlink: st.nlink });
222
- }
223
- } catch (e) {
224
- fs.closeSync(fd);
225
- throw e;
226
- }
227
-
228
- return fd;
229
- }
230
-
231
- /**
232
- * The whole write, start to finish: vet, append one JSON line, close. Callers should prefer this
233
- * over `open` — it is the shape that cannot forget to close, and every existing writer's append is
234
- * exactly one line.
235
- */
236
- function appendLine(targetPath, record, validate) {
237
- const fd = open(targetPath, validate);
238
- const line = JSON.stringify(record) + '\n';
239
- const want = Buffer.byteLength(line);
240
- try {
241
- /* THE RETURN VALUE IS NOT OPTIONAL. This was `fs.writeSync(fd, line)` with the result
242
- * discarded, which reports a SHORT WRITE as success. Forced on a 64K tmpfs: a normal-shaped
243
- * record returned true having appended a 3901-byte fragment with no trailing newline, and the
244
- * next append CONCATENATED onto it — so one ENOSPC destroys two records and leaves a single
245
- * line that no reader can parse. This is the append-only system of record, every reader
246
- * JSON.parses line by line, and all six writers route through this one call.
247
- *
248
- * Loop, because a partial write is not necessarily an error — then verify the total. A torn
249
- * tail is worse than a refused write, so if the bytes did not all land, say so loudly rather
250
- * than returning true over a corrupted ledger. */
251
- /* The size BEFORE this append, taken from the vetted fd. This is what makes the failure path a
252
- * real refusal instead of a report: if the write tears, the file is truncated back to here and
253
- * the ledger is left exactly as it was found. `nlink === 1` was already proven above, so no
254
- * other name for this file exists and the rollback cannot reach anyone else's data. */
255
- const before = fs.fstatSync(fd).size;
256
- const buf = Buffer.from(line, 'utf8');
257
- let written = 0;
258
- let cause = null;
259
- try {
260
- while (written < want) {
261
- const n = fs.writeSync(fd, buf, written, want - written);
262
- if (!(n > 0)) break; // no progress: stop rather than spin
263
- written += n;
264
- }
265
- } catch (e) {
266
- /* ENOSPC does NOT arrive as a short write. Measured on a full tmpfs: the first writeSync
267
- * short-writes 376 of 440 bytes with no error and the SECOND one throws — so a version that
268
- * only checked `written !== want` never reached its own error path, and a raw Error escaped
269
- * with name 'Error'. Everything in this repo tells a deliberate refusal from a crash with
270
- * `instanceof ForbiddenWrite` (workspace/test.js:1772 checks e.name), so an unwrapped throw
271
- * on the worst outcome this module has is the one place that signal must not be missing. */
272
- cause = e;
273
- }
274
-
275
- if (cause || written !== want) {
276
- /* THE ROLLBACK MUST NOT DESTROY SOMEONE ELSE'S RECORD, and the first version did.
277
- *
278
- * It called ftruncateSync(fd, before) unconditionally. `before` was measured at the top of
279
- * this function, so ANY append by another process in between — a complete, successful,
280
- * returned-true append — was inside the range being truncated away. Measured by an
281
- * independent pass across 5 of 5 rounds: records whose write returned `true` were missing
282
- * from disk afterwards, and the ledger was NUL-padded. A rollback that eats a committed
283
- * record is worse than the torn line it was added to prevent.
284
- *
285
- * O_APPEND makes the arithmetic decidable: our bytes are the last `written` of the file if
286
- * and only if nobody else appended. So truncate ONLY when the file is exactly the size our
287
- * own write would have made it. Otherwise the tail is not ours to remove, and the honest
288
- * outcome is to say the ledger is torn rather than to fix it by deleting a stranger's data. */
289
- let rolledBack = false;
290
- let intervened = false;
291
- try {
292
- const now = fs.fstatSync(fd).size;
293
- if (now === before + written) { fs.ftruncateSync(fd, before); rolledBack = true; }
294
- else intervened = true; // another writer appended; the tail is not ours
295
- } catch { /* reported below */ }
296
-
297
- const detail = { written, want, before, rolledBack, intervened, code: cause && cause.code };
298
- throw new ForbiddenWrite(
299
- `partial append to the ${what} ledger: ${written} of ${want} bytes landed` +
300
- (cause ? ` (${cause.code || cause.message})` : '') + '. ' +
301
- (rolledBack
302
- ? 'The ledger was truncated back to its pre-write size, so nothing was written and the ' +
303
- 'next append will not concatenate onto a fragment.'
304
- : intervened
305
- ? 'NOT ROLLED BACK ON PURPOSE — another writer appended while this write was failing, ' +
306
- 'so the bytes at the end of the file are not this one\'s to remove. The ledger holds ' +
307
- 'a torn fragment that must be repaired by hand; truncating it here would have ' +
308
- 'deleted a record that committed successfully.'
309
- : 'THE ROLLBACK ALSO FAILED — the ledger now ends in a torn line with no trailing ' +
310
- 'newline, and the next append WILL concatenate onto it, destroying a second record. ' +
311
- 'Repair it by hand before writing again.'),
312
- detail);
313
- }
314
- } finally { fs.closeSync(fd); }
315
- return true;
316
- }
317
-
318
- return { check, open, appendLine, sinkPath, __setSinkForTests };
319
- }
320
-
321
- module.exports = { createSinkGuard, ForbiddenWrite, resolveThrough };