@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2

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 (126) hide show
  1. package/components/DESIGN.md +421 -0
  2. package/components/mcp/DESIGN.md +109 -0
  3. package/components/mcp/audit.ts +21 -17
  4. package/config/DESIGN.md +306 -0
  5. package/dataLayer/DESIGN.md +179 -0
  6. package/dataLayer/restoreMarker.ts +92 -25
  7. package/dist/components/mcp/audit.d.ts +2 -1
  8. package/dist/components/mcp/audit.js +21 -17
  9. package/dist/components/mcp/audit.js.map +1 -1
  10. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  11. package/dist/dataLayer/restoreMarker.js +94 -27
  12. package/dist/dataLayer/restoreMarker.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +6 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/resources/RecordEncoder.js +25 -5
  17. package/dist/resources/RecordEncoder.js.map +1 -1
  18. package/dist/resources/Table.js +48 -2
  19. package/dist/resources/Table.js.map +1 -1
  20. package/dist/resources/analytics/write.d.ts +3 -0
  21. package/dist/resources/analytics/write.js +39 -13
  22. package/dist/resources/analytics/write.js.map +1 -1
  23. package/dist/resources/crdt.d.ts +10 -0
  24. package/dist/resources/crdt.js +22 -0
  25. package/dist/resources/crdt.js.map +1 -1
  26. package/dist/resources/databases.js +1 -1
  27. package/dist/resources/databases.js.map +1 -1
  28. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  31. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  32. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  33. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  34. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  35. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  36. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  37. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  38. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  39. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  40. package/dist/resources/recordLockCoordinator.d.ts +5 -5
  41. package/dist/resources/recordLockCoordinator.js +48 -16
  42. package/dist/resources/recordLockCoordinator.js.map +1 -1
  43. package/dist/resources/transactionBroadcast.js +4 -6
  44. package/dist/resources/transactionBroadcast.js.map +1 -1
  45. package/dist/security/auth.js +59 -23
  46. package/dist/security/auth.js.map +1 -1
  47. package/dist/security/deferredAuthentication.d.ts +11 -0
  48. package/dist/security/deferredAuthentication.js +25 -3
  49. package/dist/security/deferredAuthentication.js.map +1 -1
  50. package/dist/security/jsLoader.js +3 -0
  51. package/dist/security/jsLoader.js.map +1 -1
  52. package/dist/server/REST.js +6 -3
  53. package/dist/server/REST.js.map +1 -1
  54. package/dist/server/mqtt.js +5 -1
  55. package/dist/server/mqtt.js.map +1 -1
  56. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  57. package/dist/server/serverHelpers/serverUtilities.js +14 -3
  58. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  59. package/dist/server/serverHelpers/uwsServer.js +4 -1
  60. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  61. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  62. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  63. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  64. package/dist/utility/logging/harper_logger.js +1 -0
  65. package/dist/utility/logging/harper_logger.js.map +1 -1
  66. package/index.ts +3 -0
  67. package/npm-shrinkwrap.json +104 -104
  68. package/package.json +6 -5
  69. package/resources/DESIGN.md +568 -3
  70. package/resources/RecordEncoder.ts +27 -5
  71. package/resources/Table.ts +49 -3
  72. package/resources/analytics/DESIGN.md +38 -0
  73. package/resources/analytics/write.ts +40 -14
  74. package/resources/crdt.ts +22 -0
  75. package/resources/databases.ts +1 -1
  76. package/resources/indexes/DESIGN.md +833 -0
  77. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
  78. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  79. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  80. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  81. package/resources/record-locks.md +1407 -0
  82. package/resources/recordLockCoordinator.ts +61 -22
  83. package/resources/scheduler/DESIGN.md +40 -0
  84. package/resources/transactionBroadcast.ts +4 -4
  85. package/security/DESIGN.md +175 -0
  86. package/security/auth.ts +53 -24
  87. package/security/deferredAuthentication.ts +24 -2
  88. package/security/jsLoader.ts +3 -0
  89. package/server/DESIGN.md +264 -0
  90. package/server/REST.ts +6 -3
  91. package/server/mqtt.ts +6 -4
  92. package/server/serverHelpers/serverUtilities.ts +14 -3
  93. package/server/serverHelpers/uwsServer.ts +4 -1
  94. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  95. package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
  96. package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
  97. package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
  98. package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
  99. package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
  100. package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
  101. package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
  102. package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
  103. package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
  104. package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
  105. package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
  106. package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
  107. package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
  108. package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  109. package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
  110. package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
  111. package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
  112. package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
  113. package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
  114. package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
  115. package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
  116. package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
  117. package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  118. package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
  119. package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
  120. package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  121. package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
  122. package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
  123. package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
  124. package/studio/web/index.html +1 -1
  125. package/utility/DESIGN.md +55 -0
  126. package/utility/logging/harper_logger.ts +1 -0
@@ -0,0 +1,421 @@
1
+ # components/ — Design notes
2
+
3
+ Component deploys, the load lifecycle and packaging.
4
+
5
+ **Read this when:** touching `deploy_component`, `prepareApplication`, `EntryHandler`, `packageComponent.ts` or `deploymentRecorder.ts`.
6
+
7
+ Index of every design note: [DESIGN.md](../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## A deploy builds off to the side, is validated, and only then goes live
12
+
13
+ `deploy_component` builds the replacement at `.deploy-staging/<deploymentId>/<component>`, runs the
14
+ load validation against _that_ tree, and only then activates it. Activation is one compensating
15
+ transaction over two effects: the live tree moves into `.deploy-aside`, then the candidate is renamed
16
+ into the live path.
17
+
18
+ **Both renames wait out a holder, and the wait happens with the previous version in place.** Windows
19
+ refuses a rename outright (`EPERM`) while anything holds a handle in the source tree, which is what
20
+ `deploy_component` hit on the Windows nightly, on the swap itself. The holder was never identified —
21
+ every Harper-held handle on the candidate is closed before the swap, so it is something outside the
22
+ process, but that is inference, not evidence. `renameThroughTransientHolder` retries every rename in
23
+ the activation transaction and its recovery with capped exponential backoff against a five-second
24
+ deadline. The deadline is per top-level rename, not per activation: a redeploy held up the whole way
25
+ spends up to five seconds each on the move-aside, the swap, and the compensating restore. A rename
26
+ performed from inside a backoff shares its caller's deadline rather than opening a fourth.
27
+
28
+ The swap's backoff is not a plain sleep: it renames the aside back to the live path, waits there, and
29
+ displaces it again for the next attempt, so the component is missing for one rename rather than for the
30
+ budget. Note what that does and does not buy. Watchers are NOT the beneficiary — `Scope` pauses every
31
+ `EntryHandler` for the duration of a deploy, so an absent tree is never reported as `unlinkDir`, and
32
+ the post-deploy resume diffs against the finished tree. What the put-back protects is everything that
33
+ reads the live path directly: a component reading its own files, a lazily imported module, a concurrent
34
+ scan of the components root, and any thread whose pause the best-effort deploy broadcast did not reach.
35
+
36
+ The retry set is `EPERM`/`EACCES`/`EBUSY` only: a destination that exists is structural state nothing
37
+ here clears between attempts, and `settleInterruptedActivation` already fails that case closed rather
38
+ than guessing which tree is current.
39
+
40
+ The ordering is the design. Two things used to be wrong in a way each other hid:
41
+
42
+ - **The live tree was moved aside first**, so the component was broken for the whole extract +
43
+ `npm install`. Worse than unavailable — the live path held the _new_ code before its dependencies were
44
+ installed, so requests during a deploy hit an unrunnable tree. `stage-swap-availability.test.ts`
45
+ samples the live path through a deliberately blocked install and fails against the old ordering.
46
+ - **Validation ran after the swap committed**, so a component that installed cleanly but threw at load
47
+ went live anyway while the operation returned an error. Validation is now a callback preparation
48
+ invokes between build and activation, so a rejected candidate is never published. Note this is a
49
+ load-error PROBE, not a safety guarantee: it executes the component's own top-level code with
50
+ incomplete side-effect isolation. It also remains a no-op on the main thread, and the operations API
51
+ deploys on the main thread — so operator deploys are still unvalidated, exactly as before. Fixing that
52
+ is separate work; this only fixed the order.
53
+ **Root config is deliberately NOT part of this transaction.** It is still written before the build and
54
+ never rolled back, so `installApplications()` can reinstall a rejected release at the next restart —
55
+ unchanged from before this change. Making config an effect of the activation was implemented and then
56
+ pulled back out: it kept surfacing durability and locking problems that had nothing to do with the tree
57
+ swap (a memoized config object a disk write does not refresh, `atomicWriteFile` not fsyncing, writers that
58
+ do not share the publication lock). It is tracked as its own step so the tree half can land on its own
59
+ evidence.
60
+
61
+ ### Staging a build now and activating it later
62
+
63
+ `deploy_component { activate: false }` stops after certification, leaving a dormant artifact; a later
64
+ `deploy_component { project, deployment_id }` swaps that artifact in without resolving, fetching or
65
+ installing anything. Four things make the delay safe, and each of them exists because the immediate deploy
66
+ did not need it:
67
+
68
+ - **The artifact directory is named by the PUBLIC deployment id**, not by the deploy-lifecycle token. The
69
+ two are separate on purpose: `DeployLifecycle` de-duplicates starts by the id a start announces and
70
+ releases watcher suppression on the first matching end, so two overlapping activations of one artifact
71
+ sharing that id would count as one owner. `prepareApplication` therefore takes `artifactId` and lets
72
+ `broadcastDeployStart` keep minting a fresh token per invocation.
73
+ - **`.artifact.json` is mandatory and versioned**, written before `.complete` so the marker vouches for it.
74
+ It carries the root-config entry the build would have published (explicitly `null` for a payload deploy,
75
+ which owns none), `installationIsOpaque`, and the isolation intent that was admitted — everything a later
76
+ activation cannot re-derive. An _optional_ record could not distinguish a payload build from a package
77
+ build whose record was lost, so a missing, malformed or wrong-version one is refused rather than defaulted.
78
+ - **Claiming an id is exclusive.** `buildCandidateApplication` used to tolerate an existing deployment
79
+ directory because a fresh UUID could not collide; a public id can be repeated by an operator or by a
80
+ redelivered replication, so a claim now rejects another component's directory and any directory carrying
81
+ `.complete`, and rebuilds only over an uncertified partial of its _own_ component. Ownership is published
82
+ as part of the claim — the `.component` sidecar is written right after the exclusive `mkdir`, not at
83
+ certification — because `buildCandidateApplication` can spend minutes resolving and packing before any
84
+ tree exists to infer an owner from. For that whole window the directory answered to nobody, and an empty
85
+ `readdir` is indistinguishable from an abandoned claim, so a second component could delete a build that
86
+ was still running. **Emptiness is not a verdict:** an unattributed directory is refused, never reclaimed,
87
+ which is the same reading recovery already gives it. The id this request names is also pinned
88
+ through the preparation preamble, so retention cannot evict the artifact the request is about to use —
89
+ which it otherwise would, immediately, at `deployment_stagingRetention_maxCount: 0`. The contract is
90
+ bounded: an id names one artifact _while that artifact exists_. Activation consumes it (the swap is a
91
+ rename) and retention can prune it, after which the id is free again.
92
+ - **Staging owns its bytes.** A `file:<directory>` source is refused, and so is any symlink in the built
93
+ tree resolving outside it (bar the `node_modules/harper`/`harperdb` links the loader owns and repairs).
94
+ Certification fsyncs the tree but follows no links, and the post-swap relocation repair leaves external
95
+ targets alone — so a link out of the build is a hole in "activate exactly the bytes that were certified"
96
+ that only a delay makes reachable. **`.complete` is a durability marker over the bytes, not a seal on
97
+ them:** nothing stops a dormant artifact being edited while it waits, so the link rule and the load
98
+ validation are both re-run at activation rather than trusted from the marker. Content tampering is still
99
+ not detected — that needs a manifest the marker is bound to, and the load validation that would catch a
100
+ broken entry point is a no-op on the main thread until #2315 step 2.
101
+
102
+ Certification moved out of `activateCandidateApplication` and up into `prepareApplication` for the same
103
+ reason: `markCandidateComplete` fsyncs the whole candidate tree, and a delayed activation must not re-walk
104
+ a `node_modules` it certified at build time while holding the preparation lock. The swap primitive's
105
+ contract is now "the candidate is already certified", and its direct callers — including tests — certify
106
+ first.
107
+
108
+ **Mixed-version clusters are a known, accepted hazard.** A stage replicates as an ordinary
109
+ `deploy_component`, and a peer running a build without this change ignores the unknown `activate: false`
110
+ and deploys immediately, so it serves a release the operator asked only to stage. Nothing in core or in
111
+ harper-pro's replicator carries a peer version or capability, so the origin cannot refuse in advance; a
112
+ node that staged returns `staged: true` in its response instead, and the origin fails the stage naming any
113
+ peer that did not confirm. That is detection after the fact, not prevention — the accepted trade is
114
+ recorded on #2315, and the documented prerequisite is to upgrade every node before staging.
115
+
116
+ ### Recovering an interrupted activation
117
+
118
+ Every control file is dot-prefixed — `.activation.json`, `.artifact.json`, `.component`, `.complete`,
119
+ `.unsettled` — because
120
+ a deployment directory holds the candidate tree under the _component's_ own name beside them, and
121
+ `isJoinableComponentName` rejects a leading dot. An undotted control file shares that namespace: a component
122
+ named `activation.json` would put its tree on the journal path and activate with no journal at all, and one
123
+ named `unsettled` would make every settle throw. `assertApplicationConfig` rejects any name
124
+ `isJoinableComponentName` rejects, so the collision is unreachable from a root-config key as well as from a
125
+ deploy. Ownership inferred from a directory name is validated the same way, so a control file cannot
126
+ impersonate a component either.
127
+
128
+ A journal-LESS staging directory is not ambiguous: it is what a successful settlement leaves when its
129
+ best-effort sweep fails. Both the deploy path and boot recovery pass over one rather than failing it closed,
130
+ because a verdict written there would outlive the deployment and, once its sidecar became readable again, be
131
+ attributed to a live component that never held an unsettled activation.
132
+
133
+ An `.activation.json` journal is written beside the candidate — with a `.complete` marker recording that
134
+ build _and_ validation both succeeded — before the first rename, so `recoverInterruptedActivations()` can
135
+ settle a crash at any boundary. Both go to a temp name, are fsynced, then linked into place, so the final
136
+ name never exists with partial contents; the candidate's own contents are fsynced before `.complete` is
137
+ written, since `.complete` is what vouches for them. Recovery runs before `installApplications()`, which
138
+ installs whatever the root config names and would otherwise reinstall over a half-swapped candidate.
139
+
140
+ **Every settlement outcome clears an earlier failed recovery's `.unsettled`, including the one that returns a
141
+ staged artifact to dormant.** That branch returns before the settled tail, so it has to clear the verdict
142
+ itself: an artifact left carrying a stale marker is refused by `deployment_id` and then deleted by the next
143
+ retention pass as a stale unsettled build, which is the opposite of returning it to dormant. It also needs a
144
+ durability barrier the tail does not, because the tail removes the whole deployment directory afterwards and
145
+ this branch keeps it: with both unlinks flushed by one sync at the end, a crash can persist the journal's
146
+ removal and not the marker's, leaving a verdict no settlement will ever revisit — settlement keys on the
147
+ journal. The marker's removal is therefore flushed before the journal's — and because Windows cannot fsync a
148
+ directory, the ordering cannot be the only defence: the residue pass treats a DESCRIBED artifact carrying a
149
+ verdict but no journal as settled rather than disposable, clears the marker, and retains it. `fail()` only
150
+ ever writes `.unsettled` beside a journal it keeps, so a marker without one says settlement finished and only
151
+ the marker's own removal was lost. An undescribed build in that state stays disposable, which is the rule
152
+ that predates staging.
153
+
154
+ **Keeping the activation journal after a failed root-config undo only changes the outcome for a first-ever
155
+ deploy.** Compensation has already put an existing component's tree back and taken its rollback record with
156
+ it, so the next settle reads live-plus-candidate-with-no-record and returns the artifact to dormant whatever
157
+ the journal says — holding it there defers the same verdict to the next start and leaves the artifact
158
+ unusable until then. Only a first deploy leaves the live path absent, which recovery reads as a roll forward.
159
+ Config is stranded either way for an existing component; that is the durable-config window #2315 step 3
160
+ closes, not something the journal can cover.
161
+
162
+ **The same window costs isolation, not just a version string.** A staged artifact records the isolation the
163
+ build admitted in its `.artifact.json`, and an activation publishes that with the rest of its root-config
164
+ entry between B1 and the commit. `rollForward()` publishes nothing, so a crash after the roll-forward state
165
+ exists but before that publish brings the certified artifact up under the previous release's config — and a
166
+ component staged to run isolated comes back NON-ISOLATED, with nothing in the operation reporting it.
167
+ Isolation is a containment boundary, so weigh that window by this rather than by the version mismatch.
168
+
169
+ The journal is consulted **first**, and the legacy in-place extraction recovery enforces that itself: it
170
+ refuses to restore a rollback record while an unsettled journal is attributable to that component — by its
171
+ own `component` field OR by the deployment's ownership sidecar, whichever can be read, because restoring is
172
+ the destructive step and takes the conservative union while settlement keeps the precise intersection.
173
+ Ordering settlement
174
+ ahead of it is not enough, because a worker can be respawned mid-activation with no settlement in front of
175
+ it, and settlement that _fails_ deliberately keeps the journal while the same boot carries on. The refusal
176
+ is scoped to the branch that actually restores a tree — a record that was already retired has nothing to
177
+ restore, so that component still loads. Where no journal is attributable to the component, the legacy pass applies
178
+ unchanged: a crash in that path also leaves an in-progress aside with the live tree present, and retiring
179
+ it there would keep a half-written tree instead of restoring the good one.
180
+
181
+ Settlement runs on **every thread**, not only main, for the same reason. It is safe anywhere because each
182
+ deployment is settled under the cross-process component preparation lock and the pass is idempotent. It
183
+ _probes_ for that lock — a 250 ms try, no renewal, matching the legacy boot probe — rather than queueing:
184
+ it runs before every component load on every thread, so waiting behind a live deploy's `npm install` would
185
+ load no components at all until that install finished. A held lock means a live deploy, and a live deploy
186
+ settles its own journal.
187
+
188
+ Ambiguity exists mainly while the live path is absent, and there `.complete` is the roll-forward authority:
189
+ without it the candidate was never validated, so the committed tree in the aside wins. Live-present with a
190
+ candidate is normally pre-swap (or already rolled back) — discard the candidate — **unless a rollback
191
+ record shows the live tree had already been moved aside**. Then whatever is at the live path was recreated
192
+ afterwards by something else, and settling either way would destroy both the committed tree and the
193
+ validated candidate, so that component fails closed with both still on disk.
194
+ Live-present without a candidate is a lost tail — finish forward; never revert a completed activation.
195
+ Neither a live tree nor a rollback record is unrecoverable, so that component fails closed rather than
196
+ guessing. Every branch is idempotent, so a crash _during_ recovery is settled by the next run, and
197
+ failures are per component so one unsettleable component does not stop healthy siblings loading.
198
+
199
+ Directory fsync is best-effort by necessity — Node cannot fsync a directory on Windows — so the protocol
200
+ never depends on it. Roll-forward requires the journal, the candidate and `.complete` to all be
201
+ observable, which means a lost directory update degrades to a roll back rather than to a wrong decision.
202
+
203
+ Retiring the rollback record only marks the displaced tree disposable; both the activation path and
204
+ recovery then sweep it, or the components root would grow by a whole component version per deploy. The
205
+ retire is **correctness, not hygiene** — that marker is what stops the legacy pass treating the record as
206
+ authoritative once the journal is gone — so a failure to retire propagates and the component fails closed
207
+ with its journal intact. Only the sweep itself is best-effort, because it costs disk rather than a wrong
208
+ decision. For the same reason, a swap whose rename cannot be confirmed on storage skips both the retire
209
+ and the journal removal: the journal is what would carry the activation forward after a power loss.
210
+
211
+ Three limits are deliberate and tracked separately: activation is two renames, so the live _pathname_ is
212
+ briefly absent (in-memory resources are unaffected, but a component that opens its own files during a
213
+ request can still see a gap); validation does not run on the main-thread deploy path; and config
214
+ publication is not yet an effect of this transaction, as above.
215
+
216
+ ### Retention of dormant staged builds
217
+
218
+ A journal-less deployment directory holding `.complete` and the owner's tree is a **dormant build**: built
219
+ and validated, activated by nobody. Recovery used to remove every owned journal-less directory; it now keeps
220
+ dormant builds and bounds them per component to `deployment_stagingRetention_maxCount` (default 5, 0 keeps
221
+ none), newest by `.complete` mtime, ties broken by deployment id so concurrent passes pick the same victims.
222
+ Everything else journal-less — a partial tree, a directory whose tree already moved live, a stale
223
+ `.unsettled` — is still residue and still removed. Nothing here produces a dormant build yet beyond the crash
224
+ window between `.complete` and the journal; #2315 step 6 (deploy from an existing aside) is the producer this
225
+ bound exists for.
226
+
227
+ Removal is decided **only under the owner's preparation lock**: activation writes `.complete` moments
228
+ before its journal while holding that lock, so an unlocked read of "complete, no journal" is a candidate, not
229
+ a verdict. Boot recovery catalogues dormant builds unlocked, then reconciles each owner once: if any
230
+ catalogued directory has acquired a journal since the scan, or the owner is over its bound, it takes the lock
231
+ and re-reads only that owner's catalogued directories — never the whole staging root, which sibling threads
232
+ are probing — settling any journal that appeared (a deploy that published one and died mid-swap would
233
+ otherwise leave the component unloadable until the next start) and bounding what is still dormant. The
234
+ residue branch re-classifies under its lock too, since the `.complete` a deploy wrote before dying can land
235
+ while the scan waits for the lock. A build created after the scan waits for the next pass. It used to take the lock per journal-less directory, which was one-shot because the directory was
236
+ removed — doing that for retained builds on every pass made a healthy component lose the 250 ms probe to its
237
+ sibling threads at boot and be deferred with nothing in progress. A lock a live deploy holds is still recorded as
238
+ that same deferral: "do not delete" is not "safe to load". The deploy path prunes inside the settlement scan
239
+ it already runs under the lock, before building, so a deploy pays one traversal of the staging root.
240
+ `dropComponentDirectory` reclaims the dropped component's dormant builds, since no later deploy of that
241
+ name will. Only ENOENT is absence; any other read error keeps the entry and moves on. Pruning is disk
242
+ hygiene: it never fails a component closed and never replaces a deploy's own error, so the bound is
243
+ best-effort under filesystem failure and is not a storage quota — journaled, unsettled and unowned
244
+ directories are preserved by design and can still fill a volume.
245
+
246
+ ## Component preparation is serialized across worker threads
247
+
248
+ `prepareApplication()` performs one transaction per component: build the replacement, validate it, then swap it in (see "A deploy builds off to the side" below). Deploy operations can execute on worker threads as well as main, so a module-local promise queue is insufficient—each worker has its own module registry. `withComponentPreparationLock()` (`components/componentPreparationLock.ts`) instead acquires an atomic filesystem lock keyed by the absolute component path. The deprecated `install_node_modules` operation uses the same lock, so it cannot run npm concurrently with a deploy.
249
+
250
+ The deploy lifecycle broadcast deliberately sits _outside_ the lock. Overlapping requests therefore increment the existing per-component lifecycle refcount before queueing; watchers remain suppressed continuously until the final queued preparation ends. The lock itself covers credential materialization, extraction, and installation. Its fully-written owner record is published with an atomic rename, so contenders never observe a partially initialized lock. A preparation caller never steals a lock from a known-live owner based on elapsed wall time: installs can be long-running and clocks can jump. Locks from a dead process are reclaimed, and a same-process contender asks the main thread whether the owning worker still exists so a worker crash does not wedge that component until Harper restarts. The boot-time bulk-recovery probe is deliberately different: it never renews its 250 ms deadline, even behind another live recovery, so it can defer that component and let the worker bind its listener.
251
+
252
+ A plugin load that begins while its component is being deployed waits for that lifecycle to end before
253
+ starting `handleApplication`; if a deploy begins during the load, the plugin timeout counts only active,
254
+ unpaused load time. This prevents a long install from looking like a hung plugin while its entry handlers
255
+ are deliberately paused against the intermediate tree.
256
+
257
+ ### The component load lock is keyed by plugin type, so a plugin's promise is everyone's clock
258
+
259
+ `sequentiallyHandleApplication` (`components/componentLoader.ts`) holds a cross-thread lock keyed by the
260
+ plugin TYPE name — `graphqlSchema`, `rest`, … — not by the component. That is deliberate. Plugin modules
261
+ are per-thread singletons carrying module-level state (`server/http.ts`'s `universalHeaders` ownership
262
+ array, `resources/graphql.ts`'s `knownGraphQLDirectives`, the scheduler's register-inside-the-lock
263
+ contract), and applications load _concurrently_: `serializeComponentLoad` serializes per application
264
+ name and all applications go into one `Promise.all`. Without this key two applications' `handleApplication`
265
+ for the same plugin would interleave on a single thread, not merely across threads.
266
+
267
+ The price of that key is that whatever a plugin does inside the lock is paid by every other application.
268
+ So a plugin must return a promise that settles with its real outcome: the `withDeployAwareTimeout`
269
+ watchdog exists for a _hang_, never as the reporting path for a failure the plugin already diagnosed. A
270
+ success-only wait is what turned one unparseable schema into 30s of instance-wide gating per broken
271
+ component (#1917). `Scope.waitForInitialLoads()` is that promise — it resolves once the entry handler's
272
+ initial scan and every operation that scan started have completed, and rejects with the first failure,
273
+ after draining the rest so no sibling operation outlives the lock. The watchdog can still cut that drain
274
+ short, so the serialization the lock buys is bounded by the timeout rather than absolute.
275
+
276
+ Extraction renames an existing component aside before writing the replacement and keeps it until
277
+ dependency installation and metadata verification complete. Any preparation failure atomically
278
+ renames the partial tree into hidden staging before restoring the prior tree, so a live writer cannot
279
+ wedge rollback with `ENOTEMPTY`; cleanup completes while the same-component lock is still held.
280
+ On non-root POSIX systems, rollback uses a mode-`000` placeholder to keep that writer out between
281
+ retries. Before moving or removing it, rollback verifies the placeholder's device/inode identity and
282
+ restores owner permissions because a cross-parent directory move updates `..` and requires write
283
+ permission on the moved directory.
284
+ The aside name is itself the recovery record: an `.in-progress-*` directory or symlink preserves a
285
+ previous tree, while an `.in-progress-*-prior-absent` file records that a first deploy must remove a
286
+ partial live tree after a crash. A sibling `.retired-*` marker records that the replacement committed.
287
+ Cleanup removes the recovery record before its marker, so an interrupted cleanup cannot make obsolete
288
+ state recoverable.
289
+ Component loading recovers unretired interrupted deploys before scanning the component root, and
290
+ preparation repeats recovery under the same-component lock before reading runtime metadata. A full
291
+ `drop_component` writes retirement markers before deleting the live tree and keeps its filesystem,
292
+ and configuration mutations under that lock, so cleanup residue cannot resurrect a dropped
293
+ component and a concurrent deploy cannot interleave with the drop. Peer replication begins after
294
+ the local lock is released, and each peer serializes its own drop independently. Full-component drops
295
+ rename the live tree into staging before best-effort cleanup, avoiding an in-place recursive-delete
296
+ race with the running worker. Recovery is durable across a process crash. It relies on rename/create
297
+ ordering rather than `fsync`, so a host power loss can lose the marker.
298
+
299
+ A package-manager timeout must not release this lock while npm descendants are still mutating `node_modules`. POSIX spawns therefore run in a dedicated process group; timeout sends the group `SIGTERM`, escalates to `SIGKILL`, and waits for exit before rejecting. Windows uses `taskkill /T /F` for the equivalent process-tree termination. `manageThreads` tracks each spawned process tree by its owning Harper thread and force-terminates it if that worker exits, preventing detached installers from surviving a worker restart or Harper shutdown. `SIGKILL`/`taskkill` only queue termination, so a worker's dead-owner reclamation (above) waits for that thread's tracked process groups to be confirmed gone, not merely signaled—otherwise a replacement preparation could start while the old writer might still be alive. A process group a dead worker's own event loop spawned is never reaped from another thread, so it persists as a zombie rather than fully disappearing; since a zombie can no longer touch the filesystem, confirmation treats a zombie the same as a fully reaped exit.
300
+
301
+ Boot's `harper-application-lock.json` records an application configuration only after preparation fulfills. Recording at queue time would make a failed install look complete and suppress its retry on the next boot.
302
+
303
+ Every npm install Harper invokes directly — automatic component installation and the deprecated
304
+ `install_node_modules` operation alike — composes its arguments in `packageManagerInstallArguments()`,
305
+ which is production-only and adds `--omit=dev --no-audit --no-fund`. `--no-audit` is load-bearing, not
306
+ hygiene: npm 10 puts even a `file:` link into its audit bulk request, and the registry's answer to that
307
+ is unbounded from Harper's side. The operation accepts the established `install_allow_scripts`
308
+ spelling (and `allowInstallScripts` for compatibility), defaulting to its historical `true`; false
309
+ reaches the shared builder and adds `--ignore-scripts`.
310
+ `installApplication()` skips the package-manager child entirely when the root manifest declares no
311
+ production dependencies, non-empty workspaces, or enabled install lifecycle. An explicitly selected
312
+ non-npm manager still runs so it can discover workspace configuration outside `package.json`, and it
313
+ retains its own install defaults. A configured `install_command` remains the explicit escape hatch for
314
+ build-time tooling, but not for lifecycle-script policy: unless `install_allow_scripts` is true, its
315
+ spawn gets `npm_config_ignore_scripts=true`, which covers npm nested anywhere in the command without
316
+ adding an argument that could break non-npm tooling. The setting uses npm's configuration namespace;
317
+ other package managers that consume `npm_config_*` options can honor it too. When the policy is
318
+ omitted, Harper warns that package lifecycle scripts—including `npm run` pre/post hooks—are suppressed
319
+ and names both the operations-API and root-config opt-ins.
320
+ `readInstalledPackageMetadata()` must use the same automatic-work predicate so a
321
+ dev-only npm manifest does not force a restart on every redeploy for lacking a lockfile while an
322
+ explicit non-npm workspace install still does. Absolute local archives are classified before
323
+ package-protocol detection: a Windows drive letter's colon is path syntax, not an npm protocol. File
324
+ type detection remains asynchronous in extraction. Bare absolute Windows directory inputs retain
325
+ npm's copy/pack behavior rather than becoming live links; explicit `file:` and relative directory
326
+ inputs retain their existing symlink behavior.
327
+
328
+ ## Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation
329
+
330
+ `readPayloadBlobWithRetry` (`components/deploymentRecorder.ts`) wraps the peer's read of a replicated `hdb_deployment` row's `payload_blob` so a transient 503 `BlobReadError` (`BLOB_UNAVAILABLE_STATUS`, `resources/blob.ts`) — content bytes not arriving within `blobReadTimeout`, e.g. a parked blob send on the origin — retries instead of failing the whole deploy. Two non-obvious constraints shaped the design:
331
+
332
+ - **Retry is only safe before any byte has reached the consumer.** Once a chunk is handed downstream, re-opening `Blob.stream()` from byte 0 would duplicate it (there's no cheap way to resume from an arbitrary offset across a fresh stream without also plumbing `Blob.slice()`, which was out of scope for this fix). So the helper retries only while the current attempt has yielded nothing yet; a stall after partial content fails immediately, same as before this existed.
333
+ - **Backpressure and cancellation are two different problems, and both are easy to get wrong with a hand-rolled `ReadableStream`.** An early version wrapped the retry loop in `new ReadableStream({ async start(controller) { for await (...) controller.enqueue(chunk) } })` — this eagerly drains `streamFactory()` regardless of `controller.desiredSize`, defeating the whole point of not buffering a multi-GB payload in memory. Switching to an `async function*` consumed via `Readable.from()` restores real backpressure (the generator only resumes when the consumer wants more, matching how the un-wrapped `Blob.stream()` behaved pre-fix). But `Readable.from()`'s `return()`-on-destroy cancellation only takes effect at the generator's _next_ `yield` — while the loop is stuck retrying (no `yield` reached yet), destroying the `Readable` does nothing until the loop naturally exits, verified empirically (a generator that never yields kept retrying long after `.destroy()`). The fix: thread the constructed `Readable` back into the generator via a mutable cell (`readable` doesn't exist until after `Readable.from()` returns, so it can't be closed over directly) and check `.destroyed` explicitly at each loop iteration and after each backoff sleep.
334
+
335
+ ## A dangling symlink silently truncates the deploy tarball (`components/packageComponent.ts`)
336
+
337
+ Packaging uses `tar-fs.pack(dir, { dereference: true })` by default (`skip_symlinks` off).
338
+ tar-fs's own walker calls `fs.stat` (not `lstat`) on every discovered entry when dereferencing; a
339
+ dangling symlink's target throws `ENOENT`, and tar-fs's `statAll` loop treats _any_ `ENOENT` from
340
+ a walk-discovered (not explicitly-requested) entry as end-of-stream — it calls `pack.finalize()`
341
+ immediately, silently dropping every entry still queued (BFS order) after the link. No error is
342
+ ever emitted, so `packStream.on('error', ...)` never fires and `deploy_component` reports success
343
+ on a truncated archive. `scanPackageDirectory()` now pre-walks the tree once (async) to build a
344
+ skip-set of dangling symlinks, which `streamPackagedDirectory`'s `tar.pack({ ignore })` consults via
345
+ a synchronous `Set.has()` — **`ignore` is called synchronously by tar-fs with no Promise support**,
346
+ so any fix here has to resolve the dangling set _before_ constructing `tar.pack`, not from inside
347
+ the callback (an earlier draft used `lstatSync`/`statSync` per entry there, which would have added
348
+ blocking I/O to a path that also runs inline on the Harper server's event loop via the
349
+ `package_component` operation). The scan recurses into _valid_ symlinked directories the same way
350
+ tar-fs's dereferenced walk does (readdir through the link), since a dangling symlink nested inside
351
+ one is just as capable of tripping the same early-finalize — skipping recursion into symlinked
352
+ dirs there would silently reintroduce the bug for that case. Circular directory symlinks are not
353
+ guarded against (in the scan or in tar-fs's own pack walk); that's a pre-existing tar-fs limitation
354
+ this fix doesn't attempt to solve. `deploy_component`/`package_component` still never validate that
355
+ declared entry points (`jsResource`/`graphqlSchema`) survived extraction — a truncation from some
356
+ other future cause would still report success silently; that's a deferred, separate fix.
357
+
358
+ ## Deploy watcher generations preserve logical entry events
359
+
360
+ Component deploys pause each scope's `EntryHandler` while the component directory is replaced. A
361
+ new chokidar instance then performs a cold-style initial scan, which reports every surviving path
362
+ as `add`/`addDir` and cannot report paths that disappeared. Exposing those raw scan events changed
363
+ the public `scope.handleEntry()` contract in #1806: consumers could no longer distinguish an
364
+ unchanged file from a changed one, and deletions vanished entirely.
365
+
366
+ `EntryHandler` therefore owns the deploy boundary. It retains a compact snapshot of matching paths
367
+ (entry kind, URL path, and a SHA-256 content digest for files), assigns each watcher a monotonically
368
+ increasing generation, and compares the resumed generation's scan with the pre-pause snapshot. The
369
+ comparison emits only logical `add`, `change`, `unlink`, `addDir`, and `unlinkDir` events; unchanged
370
+ entries remain silent. File contents are still read once for the event payload and are not retained
371
+ in the snapshot. Reads and readiness are generation-scoped, and a per-path sequence prevents a slow
372
+ read from an obsolete event from overwriting a newer state. Missing paths are synthesized as unlink
373
+ events only after the resumed scan and all of its reads complete.
374
+
375
+ Every watcher recreation uses the same comparison. The first generation compares against an empty
376
+ snapshot and therefore retains its cold-load `add` behavior; deploy resume, configuration updates,
377
+ and polling recovery compare against the last completed generation. This keeps file identity intact
378
+ when watcher recovery could otherwise replay stale modules as new and ensures an update racing a
379
+ deploy scan cannot discard its removals. New component deploys still use `Application#isNewComponent`
380
+ to mark a restart as required for #674; other existing-component redeploys request a restart only when
381
+ their logical entry, loaded runtime, or configuration changes require one.
382
+
383
+ ## Restart-free deploys require proof of runtime equivalence
384
+
385
+ `EntryHandler` intentionally observes only the files a component declares in its `files` option. It
386
+ cannot prove that the JavaScript runtime is unchanged: a watched `resources.js` can import an
387
+ unwatched `lib/db.js`, and installed dependencies live under the watcher's ignored `node_modules`
388
+ tree. Conversely, hashing the entire extracted tree treats unused source and generated caches as
389
+ runtime changes and collapses restart-free deploys back into unconditional restarts.
390
+
391
+ Runtime equivalence is therefore layered. A deploy can remain restart-free only when all three
392
+ layers it uses are proven equivalent:
393
+
394
+ - `EntryHandler` compares consumer-visible watched entries.
395
+ - `ApplicationScope` records the file URL and load-time digest of every application-local module
396
+ that Harper's VM or compartment loader reads, including application-local package imports and
397
+ package self-references, together with every application-local resolution edge. After a deploy,
398
+ those exact logical paths are re-read and each edge is resolved again against the replacement tree
399
+ after evicting Node's matching resolution-cache entry. Adding a higher-priority `foo.js` ahead of a
400
+ previously resolved `foo.json` is therefore a runtime change even when `foo.json` itself is
401
+ byte-identical. For import-only package exports that Node's CommonJS resolver cannot resolve, the
402
+ package manifest itself is recorded as a runtime input so an exports-map retarget is also observable.
403
+ - The deploy pipeline compares dependency metadata at the same preparation stage: the previous
404
+ installed tree before extraction versus the replacement tree after installation.
405
+
406
+ Loader or installer paths that Harper cannot observe are conservative. Full native module loading,
407
+ custom install commands, enabled install scripts, payloads that already contain `node_modules`, and
408
+ installs without deterministic lock evidence mark the runtime opaque; an existing component using an
409
+ opaque path requires a restart on redeploy. `harper deploy` omits `node_modules` by default; callers of
410
+ `package_component` that want restart-free comparison must likewise set `skip_node_modules: true`.
411
+ Npm dependencies delegated from the default VM loader to Node's native loader are instead covered
412
+ by the installed package/lock comparison—otherwise the default `dependencyLoader: auto` mode would
413
+ make nearly every application opaque. Explicit `dependencyLoader: native` remains authoritative;
414
+ an application-local import delegated by that setting marks the runtime opaque. `package.json` is compared as parsed JSON so formatting and
415
+ key order are irrelevant, while lockfiles remain exact installed-tree evidence. A module first
416
+ loaded while a deploy is in flight also invalidates the old runtime rather than letting a mixed
417
+ generation appear equivalent. This is deliberately proof-oriented: an unused new local file need
418
+ not restart a fully observed runtime, but a changed or missing imported helper, changed resolution
419
+ input, changed dependency evidence, or any genuinely opaque runtime does. Entry changes themselves
420
+ remain consumer-directed: the static plugin applies asset changes incrementally, while executable
421
+ consumers such as `jsResource` request a restart on their logical `change` or `unlink` events.
@@ -0,0 +1,109 @@
1
+ # components/mcp/ — Design notes
2
+
3
+ The MCP Streamable-HTTP surface.
4
+
5
+ **Read this when:** adding or changing an MCP method, tool, prompt or profile.
6
+
7
+ Index of every design note: [DESIGN.md](../../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## MCP protocol surface (`components/mcp/`)
12
+
13
+ The MCP Streamable-HTTP transport (spec `2025-06-18`) is served at `/mcp` under **two profiles**: an
14
+ _operations_ profile (mounted on the Fastify operations server) and an _application_ profile (mounted on
15
+ the Harper application HTTP server). Both share `transport.ts` (the JSON-RPC dispatcher) but differ in what
16
+ they expose — operations surfaces management operations as tools; application surfaces exported
17
+ Resources/tables. Profile gating runs throughout (`completeResourceArgument`, prompt visibility, tool
18
+ `visibleTo`), so when adding a method, decide which profile(s) it belongs to rather than assuming both.
19
+
20
+ A handful of design points are non-obvious and easy to break:
21
+
22
+ - **Per-call POST SSE streaming has a close-before-subscribe race.** A `tools/call` that opts into streaming
23
+ (`Accept: text/event-stream`) gets an `IterableEventQueue` whose frames the adapter consumes via the event
24
+ API (`on('data')` / `once('close')`), **not** `for await` — the async iterator does not terminate on
25
+ `'close'`. The streaming tool handler is therefore dispatched inside a `setImmediate` (a _detached_,
26
+ deferred task) so the adapter's consumer attaches **before** any frame is produced. Without the defer, a
27
+ fast handler emits its final frame + `close` synchronously; the queue buffers `'data'` but not `'close'`,
28
+ and the stream hangs. Any handler on this path must check `signal.aborted` first (cancellation can land
29
+ before the deferred task runs).
30
+
31
+ - **Server→client requests are correlated across workers.** `serverRequests.ts` lets a streaming `tools/call`
32
+ call _back_ into the client (`sampling/createMessage`, `elicitation/create`, `roots/list`) and await the
33
+ reply. The request frame rides **the call's POST SSE stream**; the client's response is a _fresh POST_ that
34
+ can land on **any worker**. The pending-promise registry is per-worker, so a response with no local match
35
+ is fanned out over ITC (`MCP_CLIENT_RESPONSE`) and the worker holding the promise resolves it (mirrors
36
+ `components/status/crossThread.ts`). Request ids are `srv-${randomUUID()}` — **not** a per-worker counter,
37
+ which would collide on `(sessionId, id)` across workers and misroute responses. Methods are capability-gated
38
+ (`METHOD_CAPABILITY`) against the client capabilities captured at `initialize`; the registry is bounded
39
+ (timeout + high-water-mark) so a non-responding client can't leak promises.
40
+
41
+ - **Application tools must be rebuilt after JS resources register, not just on schema changes.** The
42
+ application-profile tool scan (`registerApplicationTools`) runs at MCP component boot and on schema-change
43
+ ITC events — both of which fire while the `@table` classes register, **before** the `jsResource` plugin
44
+ registers the component's exported `class X extends tables.X` subclass. That subclass is the object the
45
+ registry ends up holding (REST routes to it) and the only place author opt-ins (`static mcpTools`/
46
+ `mcpPrompts`) live, so a scan that ran earlier sees only the base table class and misses them (#1448). The
47
+ fix: `jsResource` fires `signalResourcesRegistered()` (a deliberately **local-only**, non-ITC signal in
48
+ `utility/signalling.ts`, backed by `resourceHandler` in `server/itc/serverHandlers.js` — each worker
49
+ registers its own JS resources, so the rebuild belongs in that worker) after registration; `listChanged`
50
+ subscribes and re-runs the scan. Consequence: the verb tools (`create_*` etc.) now bind to the subclass and
51
+ honor its `post`/`patch` overrides, matching REST — previously they bound to the base table class and
52
+ silently bypassed those overrides. Advertised CRUD output schemas are still table-derived, so an overridden
53
+ write verb whose return diverges from `{ id }`/`{ ok }`/`{ deleted }` advertises a subset shape (the in-use
54
+ SDK tolerates supersets; tightening per-override envelopes is sibling-issue work — see the `derive.ts`
55
+ envelope note).
56
+
57
+ - **Resource subscriptions are row-backed via the audit log.** `resources/subscribe` resolves the URI to a
58
+ Resource and drives `Table.subscribe` off the audit-store `'committed'` path (same machinery as the
59
+ "Audit-store `'committed'` notification batching" section above). The targeting is the subtle part:
60
+ `getMatch` returns the matched Resource plus the remaining path on `relativeURL`, and `subscribeToResource`
61
+ sets **both** `request.id` (the record key, or `undefined`) **and** `request.isCollection` from it. A record
62
+ URI (`…/WorkItem/42`) watches that record; a collection URI (`…/WorkItem`, what `resources/list` advertises)
63
+ watches the whole table. `new RequestTarget(path)` parses an id out of the path on its own, so _both_ fields
64
+ must be overridden — otherwise a collection URI silently watches a phantom record named after the resource
65
+ and receives nothing. `harper://*` pseudo-resources are **list-changed-only** (not row-backed). Subscriptions
66
+ use `omitCurrent` (notify on change, not a retained snapshot — the notification just says "re-read this").
67
+
68
+ - **Subscribe requires a live GET stream; teardown is asymmetric.** `resources/subscribe` rejects (`-32602`)
69
+ if no GET SSE stream has registered the session — the audit-log iterator has nowhere to deliver, and there'd
70
+ be no `RegisteredSession` close hook to stop it. The GET `'close'` handler drops **subscriptions only**
71
+ (`dropSessionSubscriptions`), _not_ pending server requests: those ride the per-call POST stream, so a normal
72
+ GET reconnect must not reject an in-flight `ctx.serverRequest`. A `DELETE` (explicit session teardown) drops
73
+ **both**, because it may arrive with no open GET stream.
74
+
75
+ - **SSE resumability (`Last-Event-ID`).** Every GET-channel frame goes through `pushSessionFrame`, which
76
+ assigns a monotonic event id and appends to a bounded per-session `replayBuffer`. On reconnect with a
77
+ `Last-Event-ID` header, `replaySince` re-sends only the frames after that id. The event-id sequence **and**
78
+ the buffer carry across a supersede (a fresh GET replacing the old one for the same session id), so ids stay
79
+ monotonic and no frame is lost across a reconnect.
80
+
81
+ - **Test seams avoid loading thread/audit machinery in unit tests.** `_setSubscribeImplForTest`
82
+ (`resources.ts`) and `_setItcForTest` (`serverRequests.ts`) inject fakes so the unit suite needn't spin up
83
+ the audit log or ITC. Consequence: the subscribe **targeting** logic (`id`/`isCollection` derivation) is
84
+ _bypassed_ by the seam and is therefore covered at the **integration** level (`sse-listchanged.test.ts` N3
85
+ record / N4 collection), not in unit tests.
86
+
87
+ Two related traps: the create/schema-update path's exclusive `update-attributes` lock is a
88
+ synchronous bounded wait (`acquireUpdateAttributesLock` in `Table.ts`: brief hot spin, then
89
+ `Atomics.wait` backoff, retryable `ServerError` after the 10s `UPDATE_ATTRIBUTES_LOCK_TIMEOUT` — harper#2251; it used to be
90
+ an unbounded `while (!tryLock()) {}` spin that pinned a worker core forever if the holder never
91
+ released). Release is structural — `table()` releases in a single `finally` and `dropTable` uses
92
+ `withUpdateAttributesLock` — so a throw inside the locked window cannot leak the lock (regression
93
+ suite: `unitTests/resources/updateAttributesLock.test.js`). Because the acquire can now throw,
94
+ `table()` takes the RocksDB lock _before_ it mutates the live `Table` (attributes, class metadata,
95
+ index handles): losing the race then leaves this worker's in-memory schema exactly as it found it,
96
+ and moving any mutation above that acquire reintroduces schema drift the catalog never saw. LMDB
97
+ keeps the lazy acquire — its `exclusiveLock()` is an environment-wide write transaction that cannot
98
+ time out, so taking it eagerly would stall every write to the database on an unchanged reload. A
99
+ successful acquire that waited past `UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT` (1s) warns once, since
100
+ contention is otherwise invisible until it becomes a timeout. The locked
101
+ sections MUST stay synchronous: the wait blocks the event loop, so an awaited operation inside
102
+ one would stall a concurrent acquirer to its deadline. And dropping then recreating a
103
+ same-named table within one process requires @harperfast/rocksdb-js >= the column-family
104
+ eviction fix (1.4.3 / rocksdb-js#<main PR>): older bindings keep the dropped column family's
105
+ by-name registry entry alive whenever other worker threads hold handles, so the recreate
106
+ silently reuses a dangling handle and every write fails with "Invalid column family specified
107
+ in write batch", poisoning the whole database env until restart. The regression suite for all
108
+ of this is `unitTests/resources/dropTableGhost.test.js` (it fails by design on pre-fix
109
+ bindings).
@@ -6,7 +6,7 @@
6
6
  * (session id, profile, tool name).
7
7
  *
8
8
  * Argument summarization runs through a redaction step that drops anything
9
- * that looks like a credential (key/secret/password). Operators who need
9
+ * that looks like a credential (secret/password/token). Operators who need
10
10
  * stricter PII handling configure `mcp.audit.argumentRedactor` to a custom
11
11
  * function via a future component-author hook (v1.1).
12
12
  */
@@ -25,17 +25,17 @@ export interface AuditEntry {
25
25
  }
26
26
 
27
27
  const REDACTION_PATTERN = /(secret|password|token|api[-_]?key|credentials?|auth)/i;
28
- // Exact field names that carry secret material without a credential-looking name: `value`/`values`
29
- // (set_secret plaintext, set_env_value .env secrets) and `envelope` (set_secret ciphertext). These
30
- // mirror the fields processLocalTransaction strips from the REST operations log — the MCP audit
31
- // path must not become a bypass. Exact-match so e.g. `search_value` stays auditable.
32
- const REDACTION_EXACT_FIELDS = /^(value|values|envelope)$/i;
33
- // ...but only for the operations that actually put secrets in those generically-named fields.
34
- // `value`/`values` are common, non-secret params on many other operations (record data, config
35
- // values), so blanket-redacting them would gut the audit trail for unrelated tools. The global
36
- // REDACTION_PATTERN still applies to every tool; this just narrows the exact-field masking to the
37
- // secret-bearing ops. NOTE: extend this set if a new op ever carries a secret in a plain field.
38
- const EXACT_FIELD_TOOLS = new Set(['set_secret', 'set_env_value']);
28
+ // Both secret tools carry all three fields, not just the ones each declares: MCP forwards arguments
29
+ // as-is and audits them after the handler, so a field the tool rejects is still logged. Mirrors what
30
+ // the REST operations log strips unconditionally (UNLOGGABLE_OPERATION_FIELDS).
31
+ const SECRET_VALUE_FIELDS: ReadonlySet<string> = new Set(['value', 'values', 'envelope']);
32
+ // Per tool, not global: `value`/`values` are ordinary params on many other operations.
33
+ const REDACTION_FIELDS_BY_TOOL = new Map<string, ReadonlySet<string>>([
34
+ ['set_secret', SECRET_VALUE_FIELDS],
35
+ ['set_env_value', SECRET_VALUE_FIELDS],
36
+ ['add_ssh_key', new Set(['key'])],
37
+ ['update_ssh_key', new Set(['key'])],
38
+ ]);
39
39
  const REDACTION_PLACEHOLDER = '[redacted]';
40
40
  const MAX_REDACTION_DEPTH = 10;
41
41
 
@@ -46,23 +46,27 @@ const MAX_REDACTION_DEPTH = 10;
46
46
  * a credential buried below the depth limit cannot leak. Returns a shallow
47
47
  * clone — the caller's payload is never mutated.
48
48
  */
49
- export function redactArgs(value: unknown, depth = 0, redactExactFields = false): unknown {
49
+ export function redactArgs(value: unknown, depth = 0, redactionFields?: ReadonlySet<string>): unknown {
50
50
  if (value === null || typeof value !== 'object') return value;
51
51
  if (depth > MAX_REDACTION_DEPTH) return REDACTION_PLACEHOLDER;
52
52
  if (Array.isArray(value)) {
53
- return value.map((v) => redactArgs(v, depth + 1, redactExactFields));
53
+ return value.map((v) => redactArgs(v, depth + 1, redactionFields));
54
54
  }
55
55
  const out: Record<string, unknown> = {};
56
56
  for (const [k, v] of Object.entries(value)) {
57
- if (REDACTION_PATTERN.test(k) || (redactExactFields && REDACTION_EXACT_FIELDS.test(k))) {
57
+ if (REDACTION_PATTERN.test(k) || redactionFields?.has(k.toLowerCase())) {
58
58
  out[k] = REDACTION_PLACEHOLDER;
59
59
  } else {
60
- out[k] = redactArgs(v, depth + 1, redactExactFields);
60
+ out[k] = redactArgs(v, depth + 1, redactionFields);
61
61
  }
62
62
  }
63
63
  return out;
64
64
  }
65
65
 
66
+ export function redactArgsForTool(value: unknown, tool: string): unknown {
67
+ return redactArgs(value, 0, REDACTION_FIELDS_BY_TOOL.get(tool));
68
+ }
69
+
66
70
  /** Mask a session id for logging — first 8 chars, suffix elided. */
67
71
  export function maskSessionId(id: string): string {
68
72
  if (typeof id !== 'string' || id.length <= 8) return id;
@@ -79,7 +83,7 @@ export function emitAuditEntry(entry: AuditEntry): void {
79
83
  const masked = {
80
84
  ...entry,
81
85
  sessionId: maskSessionId(entry.sessionId),
82
- args: redactArgs(entry.args, 0, EXACT_FIELD_TOOLS.has(entry.tool)),
86
+ args: redactArgsForTool(entry.args, entry.tool),
83
87
  };
84
88
  harperLogger.info({ category: 'mcp.audit', ...masked });
85
89
  } catch (err) {