@factoidal/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,570 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ **A parsed document is a set of quads** (RDF 1.1 Concepts §3 and §4;
6
+ https://github.com/danbri/factoidal/issues/972). Up to 0.12.0 a document
7
+ that stated the same triple twice gave a graph with two copies: `size`
8
+ counted 2 and `SELECT (COUNT(*) AS ?n)` answered 2. Every parser, the
9
+ streamed parse, the command's `query` and the stores now keep one copy.
10
+ Language tags compare case-insensitively (RDF 1.2 Concepts §3.4), so
11
+ `"a"@en` and `"a"@EN` are one term in a graph and in a store dictionary
12
+ (https://github.com/danbri/factoidal/issues/977).
13
+
14
+ SPARQL and RDF semantics fixes:
15
+
16
+ - `=` and `!=` follow RDFterm-equal (SPARQL 1.1 §17.4.1.7). Terms of
17
+ different kinds (IRI, blank node, literal, triple term) give `=` false.
18
+ Two literals that are different terms give a type error, except where
19
+ the §17.3.1 extension of the W3C tests gives false: `rdf:langString`
20
+ against another datatype, and `xsd:dateTime` against `xsd:date`. The
21
+ SPARQL 1.0 open-world tests: 18 pass, 0 fail (out of 18); were 13 pass,
22
+ 5 fail.
23
+ - `rdf:XMLLiteral` values compare by RDF term equality; SPARQL `=` on two
24
+ different XMLLiteral terms is a type error
25
+ (https://github.com/danbri/factoidal/issues/963). The RDF/XML parser's
26
+ exclusive XML canonicalization is repaired.
27
+ - A query with `FROM` clauses queries the RDF merge of those graphs
28
+ (SPARQL 1.1 §13.2.1): blank nodes from different `FROM` graphs are no
29
+ longer joined (https://github.com/danbri/factoidal/issues/979).
30
+ - RDFS closure rule rdfs9 accepts a blank node as the sub-class
31
+ (RDF 1.1 Semantics §9.2; https://github.com/danbri/factoidal/issues/982).
32
+ - The RDF/XML writer keeps the base direction of a directional language
33
+ string (`its:dir` with `rdf:version="1.2"`). The JSON-LD writer still
34
+ drops it: https://github.com/danbri/factoidal/issues/984.
35
+ - Opening a dataset renames blank nodes inside triple terms as well as at
36
+ the top level.
37
+ - `factoidal sample-store` under Deno printed its path three times: the
38
+ pack and the activate each re-executed the whole command. It now
39
+ re-executes once, before any work.
40
+
41
+ **`proofCheck` no longer joins blank nodes across artifacts**
42
+ (https://github.com/danbri/factoidal/issues/976). Up to 0.12.0 a bundle
43
+ could take `:A rdfs:subClassOf _:x` from one graph artifact and
44
+ `_:x rdfs:subClassOf :C` from another with the same RDFC-1.0 digest, and
45
+ conclude `:A rdfs:subClassOf :C` with `valid: true`. Blank nodes are now
46
+ scoped to the artifact or assumption they come from (RDF 1.1 Semantics
47
+ section 5.2), so that bundle is refused. A chain inside one artifact, and
48
+ a chain across artifacts through triples with no blank node, are still
49
+ accepted. The report has a new member `rejection`: `null` for a valid
50
+ bundle, else the first failed check in words. The bundle format and the
51
+ profile `fpp0/1` are unchanged.
52
+
53
+ **XMPP server from wasm.** `factoidal xmpp-serve DIR` and
54
+ `@factoidal/core/xmpp/server` run the Lean XMPP server
55
+ (`L4Factoidal.XMPP.Server` — RFC 6120 negotiation, SASL PLAIN and
56
+ SCRAM-SHA-256, binding, RFC 6121 roster/presence/messages) in the
57
+ WebAssembly engine, one engine session per TCP or direct-TLS (XEP-0368)
58
+ connection. New engine ops `xmppOpen`, `xmppSessionOpen`,
59
+ `xmppSessionFeed`, `xmppSessionDeliver`, `xmppSessionClose`,
60
+ `xmppRosterGet`, `xmppRosterSet`, `xmppClose`
61
+ (`formal/lean4/Wasm/Ops/XmppSession.lean`). The state directory is the
62
+ native carrier's layout (`accounts`, `roster/`, `spool/`), so a stanza
63
+ written as a file under `spool/<bare jid>/` is delivered to that
64
+ account. See `xmpp/README.md`.
65
+
66
+ Linked Web Storage kept in files, stage 2 (https://github.com/danbri/factoidal/issues/918;
67
+ design record `docs/designissues/2026-10-22-lws-storage-on-disk.md`):
68
+
69
+ - Stage 2 (`L4Factoidal/LWS/Bodies.lean`): with `--storage files` the bodies
70
+ stay in their body files. An entry keeps its length, its FNV-1a and SHA-256
71
+ digests and the types of a Turtle body, and no bytes (ACL resources keep
72
+ theirs). A step that needs bytes (GET body or range, JSON Patch, a PUT a
73
+ subscription may hear of, a move of a Turtle resource) answers
74
+ `read: [PATH, ...]`; the host reads those files and gives them to the new op
75
+ `lwsRead` (byte-region entry only), which checks them, and steps again. A
76
+ start reads no body file unless the metadata lacks a fact; the peak RSS of a
77
+ start and one GET of 1,000 and 4,000 resources of 16 KiB is 230 and 211 MiB
78
+ (stage 1: 258 and 515 MiB). A changed body file is refused when it is read
79
+ (the request answers 500), no longer at start.
80
+ - The LWS handles run a tree store (`TreeStore`, theorem `treeStoreLaws`) and
81
+ a type index in persistent trees: the engine's step and persist of a PUT
82
+ take 0.30, 0.27, 0.28 and 0.29 ms in a storage of 1,000, 2,000, 4,000 and
83
+ 16,000 resources (stage 1: 0.21 ms at 1,000, about 3 ms at 16,000). A POST
84
+ no longer lists every path; a DELETE lists paths only for a container.
85
+ - The order of the entries of a snapshot and of `lws-files.json`, and of the
86
+ activities of a recursive DELETE in one notification, is path order (was
87
+ newest first). `lwsSnapshot` of a storage kept in files is refused.
88
+ - `solid-serve` keeps the snapshot: an expected failure naming issue 918.
89
+
90
+ An authorization server as a component (https://github.com/danbri/factoidal/issues/916;
91
+ design record `docs/designissues/2026-10-22-authorization-server.md`; skill
92
+ `skills/authz-server/SKILL.md`), decided in Lean (`L4Factoidal/Authz/`):
93
+
94
+ - `factoidal authz-serve DIR --resource IRI[,IRI...]` and
95
+ `import { listen } from '@factoidal/core/authz'`: RFC 8414 metadata at
96
+ `/.well-known/lws-configuration` (and `/.well-known/oauth-authorization-server`),
97
+ a JWK Set, and an RFC 8693 token endpoint that exchanges a subject token for
98
+ an RFC 9068 access token whose `aud` is exactly the requested resource
99
+ (lws10-core "Authorization"). `lws-serve --issuer <its issuer>` accepts its
100
+ tokens with no other setting. `factoidal authz-keys DIR` prints the JWK Set.
101
+ - Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
102
+ self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid; P-256
103
+ and Ed25519 did:keys). SAML 2.0 is refused and not advertised: there is no
104
+ XML Signature verifier (https://github.com/danbri/factoidal/issues/926).
105
+ solid-serve does not accept these tokens yet
106
+ (https://github.com/danbri/factoidal/issues/928).
107
+ - Documents the server fetches for a subject token: `https` only (`--allow-http`
108
+ for tests), never from a loopback, link-local or private address
109
+ (`--allow-private-fetch` for tests), from the address the engine checked,
110
+ with no redirect.
111
+ - Keys: ES256 by default (`--alg EdDSA`), signed by HACL\* through three new
112
+ P-256 externs (sign, secret-to-public, point decompression; RFC 6979 and
113
+ Wycheproof vectors in `lake exe l4crypto-probe`); `kid` is the RFC 7638
114
+ thumbprint; `--rotate-after` rotates and keeps the old key published until
115
+ its tokens expire; `DIR/authz-keys.json` (mode 0600) is sealed under
116
+ `FACTOIDAL_LWS_KEY` when it is set.
117
+ - New ops `authzOpen`, `authzStep`, `authzFetched`, `authzKeys`, `authzRotate`,
118
+ `authzClose`.
119
+ - Theorems: `issueToken_verifies_published` and `issueToken_audience_exact`
120
+ hold because of a run-time check of every issued token (the proof that
121
+ removes the check: https://github.com/danbri/factoidal/issues/927);
122
+ `exchange_refused_no_token` (a subject token that fails validation never
123
+ yields an access token), `cid_valid_verified`, `didKey_valid_verified`,
124
+ `oidc_valid_named_provider`, `chooseAddress_refuses_private` and others.
125
+ - Touchstone with this server in place of its reference one
126
+ (`tools/lws-touchstone.sh --config auth --authz factoidal`, 2026-10-22):
127
+ 212 passed, 0 failed, 9 inapplicable (out of 221); of these
128
+ core/authorization_server 9 pass, 0 fail (out of 9), auth/oidc 6 pass,
129
+ 0 fail (out of 6), auth/cid 22 pass, 0 fail (out of 22), auth/saml 3
130
+ inapplicable (issue 926).
131
+
132
+ CORS in the LWS HTTP binding (https://github.com/danbri/factoidal/issues/923),
133
+ decided in Lean (`L4Factoidal/HTTP/Cors.lean`, `L4Factoidal/LWS/Cors.lean`), so
134
+ a page on another origin (for example GitHub Pages) can call `lws-serve`:
135
+
136
+ - A CORS-preflight request (`OPTIONS` with `Access-Control-Request-Method`
137
+ and `Origin`) is answered 204 before any other decision, with no token and
138
+ no access check: `Access-Control-Allow-Methods` (with `QUERY` and `PATCH`),
139
+ `-Allow-Headers` (`Authorization`, `Content-Type`, `If-Match`,
140
+ `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`, `Link`,
141
+ `Prefer`, `Slug`, `Range`, `DPoP`, `Accept`), `-Max-Age` 7200. An `OPTIONS`
142
+ without `Access-Control-Request-Method` is answered as before.
143
+ - Every answer carries `Vary: Origin` (one `Vary` field with the answer's
144
+ own), and, for an answered origin, `Access-Control-Allow-Origin` and
145
+ `Access-Control-Expose-Headers` (`ETag`, `Link`, `Location`, `Allow`,
146
+ `Accept-Patch`, `Accept-Post`, `Accept-Put`, `Accept-Query`,
147
+ `Accept-Ranges`, `Content-Location`, `Content-Range`, `WWW-Authenticate`,
148
+ `Preference-Applied`, `Repr-Digest`, `Last-Modified`,
149
+ `Factoidal-Merkle-Root`). No `Access-Control-Allow-Credentials`: the token
150
+ goes in `Authorization`.
151
+ - Default: echo a valid request `Origin` (Solid Protocol §8.1), never `null`.
152
+ `lws-serve --cors-origin ORIGIN` (repeatable) answers only those origins;
153
+ `--no-cors` sends no CORS field; config member `cors` (`false` or a list).
154
+ A malformed origin stops the start.
155
+ - `authz-serve` gets the same (`Authz.stepCors`; methods `GET, HEAD, OPTIONS,
156
+ POST`), with the same flags. `solid-serve` keeps its own Solid §8.1 CORS;
157
+ its preflight is now answered before Web Access Control (it was 401 for a
158
+ resource that is not public).
159
+
160
+ An LWS client (https://github.com/danbri/factoidal/issues/917; design record
161
+ `docs/designissues/2026-10-22-lws-client.md`):
162
+
163
+ - `@factoidal/core/lws/client`: `createLwsClient({ fetch, subjectToken,
164
+ subjectTokenType })` with `discover`, `read`, `head`, `create`, `update`,
165
+ `patch`, `delete`, `readLinkset`, `updateLinkset`, `move`, `list`,
166
+ `typeIndex`, `search`, `subscribe`, `unsubscribe`, `requestAccess`,
167
+ `grantAccess`, `getAccess`, `revokeAccess`, `verifyNotification`; and
168
+ `createNotificationReceiver`, a webhook receiver that answers each delivery
169
+ with the engine's verdict. `fetch` is injected (Node, a browser, or a
170
+ transport such as XMPP).
171
+ - Every decision is in Lean (`L4Factoidal/LWS/Client/`, wasm ops
172
+ `lwsClientStart` and `lwsClientStep`): the 401 flow with the three checks of
173
+ lws10-core "Discovery Validation by a Client", RFC 8414 metadata and RFC 8693
174
+ token exchange; conditional writes (the current ETag by default for
175
+ `update`, `patch`, `updateLinkset`, `move`); no repeat after 405 or 415, no
176
+ POST again after a 5xx or a lost answer; paging by `rel="next"` only;
177
+ linkset writes only in advertised methods and formats; the RFC 9421 check of
178
+ a notification. Theorems: `walk_concat`, `walk_members_once`,
179
+ `step_write_precondition`, `step_no_token_request_without_validation`,
180
+ `step_no_blind_create_retry`, `step_no_repeat_after_refusal`.
181
+ - CLI `factoidal lws-client <op> URL`.
182
+ - Self-issued credentials (lws10-authn-ssi-cid): `createLwsClient({ selfIssued:
183
+ { subject, keyId, jwk } })`; the engine signs the ES256 JWT with HACL*.
184
+ - Expected failure: OpenID sign-in is not built (row names issue 917).
185
+
186
+ ## 0.12.0 — 2026-10-22
187
+
188
+ One error model for the whole package
189
+ (https://github.com/danbri/factoidal/issues/882 item 4,
190
+ https://github.com/danbri/factoidal/issues/901 KR 3.1). Every op error
191
+ envelope of the engine carries a stable `code`, the input `position` where
192
+ the input defines one, and the `spec` section where one applies; the engine
193
+ decides all three (`formal/lean4/Wasm/Ops/ErrorModel.lean`). The package
194
+ copies them into one class, `FactoidalError`. The README "Errors" table is
195
+ generated from the engine's list.
196
+
197
+ ### Breaking changes
198
+
199
+ - `DatasetHandle`, the name of `Dataset` before 0.10.0, is removed. Use
200
+ `Dataset`.
201
+
202
+ - `ParseError` is removed. Every function rejects with `FactoidalError`
203
+ (`code`, `position`, `spec`, `cause`); a parse error has
204
+ `code: 'parse.syntax'` (or `parse.rdf12-required`, `term.invalid-iri`,
205
+ `term.invalid-langtag`) and `err.position.{line, column, offset}` in place
206
+ of `err.line`, `err.column`, `err.offset`. `position.byteOffset` is new.
207
+ - Error messages are the engine's text, with no `<function>: l4factoidal:`
208
+ prefix. A parse with an inferred format no longer appends "(format inferred
209
+ as ...)" to the message; `err.formatInferred` and `err.format` say it.
210
+ - An unknown option key, an unknown format name, an `entail`, `output`,
211
+ `version`, `owlClosure` mode, `rmlMap` source kind or `Dataset.closure`
212
+ regime outside the engine's tables, and options that cannot be combined
213
+ were a `TypeError`; each is now a `FactoidalError` (`option.unknown`,
214
+ `format.unknown`, `option.invalid-value`, `regime.refused`,
215
+ `option.conflict`) decided by the new engine op `apiCheck`. A `TypeError`
216
+ stays only for an argument of the wrong JavaScript type.
217
+ - A method of a disposed `Dataset` was a `TypeError`; it is now the engine's
218
+ `FactoidalError` with code `handle.disposed`. A closed `Store` is the same.
219
+ - `queryRaw` rejected with an `Error` whose message began "SPARQL query
220
+ failed:" and which had `stderr`; it now rejects with the engine's
221
+ `FactoidalError` (`query.syntax` with a position, or `query.failed`).
222
+ - File-system and IndexedDB failures (`openStore`, the delta log) are
223
+ `FactoidalError` with code `host.io` and the original error as `cause`.
224
+ - `@factoidal/core/fn` `entail()` with an `x-ikl-*` regime rejects with code
225
+ `regime.refused` and the message names `query`, the function that refuses.
226
+
227
+ Linked Web Storage kept in files, stage 1 (https://github.com/danbri/factoidal/issues/918;
228
+ design record `docs/designissues/2026-10-22-lws-storage-on-disk.md`), decided in
229
+ Lean (`L4Factoidal/LWS/Journal.lean`, `FileLayout.lean`):
230
+
231
+ - `factoidal lws-serve DIR --storage files` (config member `storage: "files"`,
232
+ `listen({ storage: 'files' })`; the default stays `snapshot`): DIR holds one
233
+ file per resource body under `bodies/`, a journal `lws-journal` with one
234
+ record per change, and a metadata file `lws-files.json` that the journal is
235
+ folded into when it passes max(1 MiB, the size of the metadata file). A
236
+ write no longer rewrites the whole storage: a PUT into a storage of 1,000,
237
+ 2,000 and 4,000 resources took 2.70, 2.40 and 2.89 ms, against 42.6, 83.9 and
238
+ 170.4 ms with the snapshot (`tests/lws/scaling.mjs`, rows "storage").
239
+ - A journal record is valid only when it is complete and its SHA-256 matches;
240
+ a torn last record is ignored at start (theorem `decodeFrames_torn`).
241
+ Replaying the records of a sequence of steps gives the metadata of the store
242
+ after them (`replay_records`, `persist_replay`).
243
+ - With `FACTOIDAL_LWS_KEY` every body file and journal record is sealed with
244
+ ChaCha20-Poly1305 (HACL*), the file's own name in the associated data, and
245
+ `lws-files.json` as the snapshot is.
246
+ - An existing `lws-state.json` is migrated at the first start in files mode and
247
+ renamed to `lws-state.json.migrated`.
248
+ - New ops `lwsLoad` and `lwsPersist` (byte-region entry only): the engine names
249
+ the files to read and gives every file operation and byte; the host
250
+ (`lws/persist.mjs`) performs them. `tools/lws-touchstone.sh --storage files`.
251
+ - Stage 1 still holds the whole storage in memory; stage 2 (bodies read on GET,
252
+ the metadata in an on-disk index) is an expected failure in
253
+ `tests/lws/scaling.mjs` naming issue 918.
254
+
255
+ Linked Web Storage Search and Type Index Services (`lws10-index`, w3c/lws-protocol
256
+ `9a08ff65`; https://github.com/danbri/factoidal/issues/912), decided in Lean
257
+ (`L4Factoidal/LWS/TypeIndex.lean`, `Search.lean`):
258
+
259
+ - The storage description advertises a `TypeIndexService`
260
+ (`GET /.well-known/lws-type-index`, a paged `TypeIndex`) and a
261
+ `TypeSearchService` (`QUERY /.well-known/lws-type-search` with an
262
+ `application/lws-query+json` filter in conjunctive normal form over `type`
263
+ and indexed relations such as `describedby`; a paged ContainerPage).
264
+ Option `typeIndex: false` of `listen`/`openLwsStorage` turns both off.
265
+ - Types come from the native class, from `Link: <T>; rel="type"` on POST and
266
+ on `Prefer: set-linkset` PUT and PATCH, and from `rdf:type` in Turtle
267
+ content up to 1 MiB. Such a `type` link was refused with 409 before; it is
268
+ now a declared type and appears in the linkset document. A `type` link
269
+ that names an LWS or LDP class is still refused with 409. A PUT or PATCH
270
+ that applies `Prefer: set-linkset` answers `Preference-Applied: set-linkset`.
271
+ - The index is kept by the store on every write and rebuilt in one pass when
272
+ a snapshot is opened. Answers, `totalItems` and the type index hold only
273
+ what the requesting agent may read now. Theorems `ixSearch_eq_specSearch`
274
+ and `searchAnswer_authorized`.
275
+
276
+ Linked Web Storage Access Requests and Grants (LWS core §Access Requests
277
+ and Grants, w3c/lws-protocol `9a08ff65`;
278
+ https://github.com/danbri/factoidal/issues/913), decided in Lean
279
+ (`L4Factoidal/LWS/AccessPolicy.lean`, `AccessGrants.lean`):
280
+
281
+ - The storage description advertises an `AccessRequestService`
282
+ (`/.well-known/lws-access-requests/`) and an `AccessGrantService`
283
+ (`/.well-known/lws-access-grants/`), each with `conformsTo`
284
+ `https://www.w3.org/ns/lws#AccessProfile`. Both endpoints are LWS
285
+ containers: GET lists (paged), POST of an `application/lws+json` document
286
+ answers 201 with `Location`, GET and DELETE act on one member. Option
287
+ `accessGrants: false` of `listen`/`openLwsStorage` turns both off.
288
+ - Any authenticated agent creates an access request; only the storage
289
+ controller (`acl:Control` on the storage root) creates or revokes a grant.
290
+ A document without `type`, `storage` (this storage), `access`, a policy
291
+ `type` with `AccessPolicy`, `action` (`read`, `modify`, `create`,
292
+ `delete`), `assignee`, or with a bad `inbox`, `target` or constraint,
293
+ answers 400.
294
+ - A grant is part of the access policy beside Web Access Control: it permits
295
+ exactly its actions on exactly its targets (a container grant does not
296
+ reach the members) to its assignee (`foaf:Agent` for the public), when
297
+ every constraint holds: `dateTime` (`eq`, `gt`, `gteq`, `lt`, `lteq`),
298
+ `format` and `type` of the resource, `client` (the token's `client_id`)
299
+ with `eq` or `isAnyOf`; `purpose` is accepted and no request satisfies it.
300
+ A move needs `delete` of the resource and `create` in the destination.
301
+ Notification subscriptions and deliveries, and the type index answers,
302
+ see the same access. Theorems `grantsPermit_sound`,
303
+ `policyPermits_excluded`, `recordPermits_expired`, `grantsPermit_revoke`,
304
+ `grantAllows_acl`.
305
+ - A new grant is announced (one `Create` notification) to its `inbox` and
306
+ to the inbox of each access request naming one of its assignees. Grants
307
+ and requests are kept in the storage snapshot (member `access`).
308
+ - The storage controller hears of a new access request
309
+ (https://github.com/danbri/factoidal/issues/920). Core §Access Requests and
310
+ Grants: "When a new access request is submitted, the storage controller
311
+ SHOULD be notified." The draft names no inbox for the controller, so this
312
+ server's is configuration: `listen({ controllerInbox: URL })` or
313
+ `factoidal lws-serve --controller-inbox URL`. When it is set, each new
314
+ access request gives one `Create` notification there (signed, retried, as
315
+ every delivery); when it is not set, nothing is sent. An inbox that is not
316
+ `http` or `https` stops the start.
317
+
318
+ Linked Web Storage moves and notifications, three more rules
319
+ (w3c/lws-protocol `9a08ff65`), decided in Lean:
320
+
321
+ - 301 at the previous identifiers of a moved resource
322
+ (https://github.com/danbri/factoidal/issues/914;
323
+ `L4Factoidal/LWS/MoveRedirect.lean`). §Moving Resources: "Such a server
324
+ SHOULD respond to subsequent requests on the previous identifiers of the
325
+ resource and of its auxiliary resources with 301 Moved Permanently,
326
+ including a `Location` header field pointing at the new identifier." After
327
+ a move, a request to the old path of the resource, of each descendant of a
328
+ moved container, of its `.acl` and `.meta` and of its `?linkset` answers
329
+ 301 with the new IRI, when the same request at the new path would be
330
+ authorized (§Privacy Considerations, "Redirects": "Servers SHOULD apply the
331
+ same access control to such responses as to the moved resource itself");
332
+ otherwise the old path answers as before. A chain of moves answers the
333
+ current path. The table holds at most `redirectsMax` pairs (default 256,
334
+ `lws-serve --redirects-max N`), is kept in the snapshot (member `moved`),
335
+ and loses a pair when a new resource is created at its old path or the
336
+ moved resource is deleted. Theorems `redirect_current`, `after_ok`,
337
+ `redirect_after_move`.
338
+ - A webhook inbox that names this server under another host name, an IP
339
+ address or `localhost`, at its port, is refused with 400
340
+ (https://github.com/danbri/factoidal/issues/915;
341
+ `L4Factoidal/LWS/InboxGuard.lean`). The engine compares the inbox
342
+ authority with the base IRI, the bound address and the interface
343
+ addresses; for a host name it answers `lwsStep` with `resolve: {host}`, the
344
+ host resolves the name and steps the request again with `resolved`. The
345
+ host passes `listen: {address, port, addresses}` to `lwsOpen`.
346
+ - A delivery rate limit per subscription (issue 915; the webhook suite:
347
+ "Rate limiting and endpoint verification are suggested to prevent abuse of
348
+ the subscription mechanism"): at most `deliveryLimit` deliveries (default
349
+ 10) in a window of `deliveryWindow` seconds (default 60;
350
+ `lws-serve --delivery-limit N --delivery-window S`; `deliveryLimit: 0`
351
+ turns it off). The activities over the limit wait and go, when the window
352
+ ends, in one envelope with an `activity` array (core §Batching
353
+ Notifications). An answer may carry `wake: SECONDS`; the host then calls
354
+ the new op `lwsFlush`. Theorem `admits_le`: at most `deliveryLimit`
355
+ deliveries per subscription in one window. Endpoint verification: the
356
+ drafts define no procedure (no request, no challenge, no expected answer),
357
+ so there is no behaviour to implement.
358
+
359
+ Linked Web Storage, three rules of the editor's draft (w3c/lws-protocol
360
+ `9a08ff65`, 2026-10-19), all decided in Lean:
361
+
362
+ - Container pagination (§LWS Media Type, Pagination;
363
+ https://github.com/danbri/factoidal/issues/887). A container with more
364
+ than `pageSize` members (option of `listen`/`openLwsStorage`, default 100,
365
+ 0 turns it off) answers in pages: `Link` relations `first`, `last`, `next`
366
+ (not on the last page) and `prev` (not on the first page), page URIs
367
+ `CONTAINER?page=N`, `totalItems` over the full membership, one `ETag` per
368
+ page. Members are listed in increasing path order, each once (theorem
369
+ `pageItems_concat`).
370
+ - Client-managed links (§Metadata, §Update Resource;
371
+ https://github.com/danbri/factoidal/issues/888): `Link` with
372
+ `Prefer: set-linkset` on PUT (replace) and PATCH (add), `Link` on POST, and
373
+ JSON Patch or PUT of `application/linkset+json` on `PATH?linkset`.
374
+ Server-managed relations (`linkset`, `type`, `format`, `size`, `modified`,
375
+ `up`, `items`, `lws#storage`) answer 409; a malformed link answers 400.
376
+ Client links are kept in the storage snapshot.
377
+ - Notifications (§Notifications and the webhook notification suite
378
+ `lws10-notifications-webhook`;
379
+ https://github.com/danbri/factoidal/issues/890). The storage description
380
+ advertises a `NotificationService` (`subscriptionType`
381
+ `["WebhookSubscription"]`, endpoint `/.well-known/lws-subscriptions/`) and
382
+ an Ed25519 `JsonWebKey` verification method. An authenticated POST of a
383
+ `WebhookSubscription` (`topic`, `inbox`, optional `expires`) answers 201;
384
+ each topic must be readable by the agent (403 otherwise). Every successful
385
+ write gives Activity Streams 2.0 `Create` (with `target`), `Update` or
386
+ `Delete` (with `origin`) activities, sent only to subscribers who may read
387
+ the resource at that time, as an `application/lws+json` POST to the inbox,
388
+ signed with HTTP Message Signatures (RFC 9421) and `Content-Digest`
389
+ (RFC 9530). The endpoint lists the agent's subscriptions; GET and DELETE
390
+ manage one. A delivery answered 5xx, 408, 425, 429 or not at all is tried
391
+ up to three times; 410 or three failed deliveries deactivate the
392
+ subscription. Subscriptions and the signing key are kept in the storage
393
+ snapshot. The host makes the requests the engine names and reports each
394
+ status to the new op `lwsDelivered`; `listen`/`openLwsStorage` take
395
+ `notifications: false` to turn it off.
396
+ - Moving resources (§Moving Resources;
397
+ https://github.com/danbri/factoidal/issues/914). The storage description
398
+ describes a `MoveResource` capability (`appliesTo` DataResource and
399
+ Container, `preservesIdentifier` false). A JSON Patch (or PUT) of a
400
+ resource's linkset that changes its `up` link moves it to that container,
401
+ with its descendants when it is a container, in one step: the answer is
402
+ 200 with the new linkset and `Content-Location`, both containers get a new
403
+ ETag, and a subscriber hears one `Move` activity (`origin`, `target`). A
404
+ destination that is missing or not a container, a cycle, the storage
405
+ root, a name already taken, another storage or a removed `up` link is
406
+ 409. The agent needs the delete of every moved resource and the create in
407
+ the destination. `listen`/`openLwsStorage` take `moves: false` to turn it
408
+ off. Theorem `move_tree`: a move keeps the containment hierarchy a tree.
409
+ - Three server options, each off by default and decided in Lean
410
+ configuration, so that every Touchstone test that one of our
411
+ configurations can satisfy runs (`tools/lws-touchstone.sh --config all`):
412
+ `lws-serve --moves all|data|off` (`moves: "data"` describes `appliesTo`
413
+ `[DataResource]` and answers a change of a container's `up` link with 409;
414
+ `"off"` describes no `MoveResource`), `--no-linkset-put` (`linksetPut:
415
+ false`: a linkset's `Allow` leaves out PUT and a PUT answers 405 with
416
+ `Allow`, as lws10-core §Metadata requires when PUT is not advertised) and
417
+ `--paginate-always` (`paginateAlways: true`: a container listing that fits
418
+ on one page is still one paginated page, with `rel="first"` and
419
+ `rel="last"` and no `rel="next"` or `rel="prev"`). An unknown `moves`
420
+ value stops the start.
421
+ - Fixed: on a paginated container, `If-Match` with the `ETag` of its first
422
+ page (which a GET of the container URI answers) failed with 412; the
423
+ first page's tags are now current tags of the container.
424
+
425
+ Encryption at rest of the LWS and Solid storage snapshot, off by default
426
+ (https://github.com/danbri/factoidal/issues/921; owner, 2026-10-21: "Yes env
427
+ var please. Again off by default but good to show we can."):
428
+
429
+ - When the environment variable `FACTOIDAL_LWS_KEY` holds a 32-byte key (64
430
+ hex digits or base64url), `lws-serve` and `solid-serve` write
431
+ `DIR/lws-state.json` as format `lws-snapshot-sealed-1`: format id, cipher
432
+ id `chacha20-poly1305`, key id (first 8 bytes of SHA-256 of the key), a
433
+ 12-byte nonce new for every write, and the ChaCha20-Poly1305 (RFC 8439,
434
+ HACL*) ciphertext of the snapshot, with the header as associated data.
435
+ `listen`/`openLwsStorage`/`openSolidStorage` take `storageKey` (text, or
436
+ `null` to read no key).
437
+ - At start the file is authenticated before use. A wrong key, a changed or
438
+ truncated file, a clear-text file with the variable set and a sealed file
439
+ without it each stop the start with a one-line message (exit 1). With the
440
+ variable unset, behaviour is unchanged.
441
+ - It protects the file on disk only: not the memory of a running server, not
442
+ the key in its environment, not the answers to clients.
443
+ - Decided in Lean (`L4Factoidal/LWS/SnapshotSeal.lean`), with theorems over
444
+ any AEAD with RFC 8439 correctness (`unsealDoc_sealDoc`,
445
+ `unsealDoc_authenticated`, `unsealDoc_wrongKey`, ...). `lwsSnapshot` and
446
+ `solidSnapshot` take an optional second argument `{"nonce":…}`.
447
+ - Cost with a key (2026-10-21, about 1,050 resources): a write takes about
448
+ 2.7 times as long (133 to 143 ms against 49 to 54 ms), the file is 1.33
449
+ times larger, a restart about 2.1 times as long.
450
+
451
+ ### Breaking changes
452
+
453
+ - The storage description has a second service, `NotificationService`, and
454
+ `verificationMethod` and `authentication` members; its `ETag` is a digest
455
+ of the document.
456
+ - A container listing is in increasing path order. Before, it was in the
457
+ order of the store (newest write first).
458
+ - A container with more than 100 members returns only its first 100 members
459
+ unless `pageSize` is set; follow `rel="next"`.
460
+ - PUT on a linkset resource replaces the client links. Before, it answered
461
+ 405.
462
+
463
+ ### Unstable
464
+
465
+ - ANTICIPATORY AND UNSTABLE, off by default: a type search results resource
466
+ (https://github.com/danbri/factoidal/issues/922). With
467
+ `listen({ searchResults: true })` or `factoidal lws-serve
468
+ --unstable-search-results`, a QUERY answer of the Type Search Service by an
469
+ authenticated agent carries `Content-Location` naming
470
+ `/.well-known/lws-type-search/results/<id>` (RFC 10008 section 2.3).
471
+ lws10-index defines no such resource; its path, lifetime, snapshot member
472
+ and behaviour are likely to change. A GET by the same agent and client lists
473
+ the QUERY's results that a new QUERY would still answer (paged, filtered by
474
+ current read access); anyone else gets 404; after `searchResultsTtl`
475
+ (600 s) 410. The storage description marks it with the extension member
476
+ `https://github.com/danbri/factoidal/issues/922#unstableResultsResource`.
477
+ `lwsStep` request documents may carry `random` (16 bytes, base64); the LWS
478
+ host sends it with every request. Decided in Lean
479
+ (`L4Factoidal/LWS/SearchResults.lean`).
480
+ - Off by default: Merkle hashes of the LWS storage
481
+ (https://github.com/danbri/factoidal/issues/919; owner, 2026-10-21: "As an
482
+ optional, probably default off until we settle on stronger motivating
483
+ usecase but good to have the fundamentals there"). With
484
+ `listen({ merkle: true })` or `factoidal lws-serve --merkle`: a GET or HEAD
485
+ of a resource carries `Repr-Digest: sha-256=:<base64>:` (RFC 9530, SHA-256
486
+ of the stored bytes, HACL*); a container listing carries
487
+ `Factoidal-Merkle-Root` and the member
488
+ `https://github.com/danbri/factoidal/issues/919#merkleRoot` (a Merkle digest
489
+ over its members and their digests); the storage description carries
490
+ `https://github.com/danbri/factoidal/issues/919#storageMerkleRoot`. Under
491
+ authorization the roots go to the storage controller only. Entity tags do
492
+ not change. The hashes are kept by the store on every write, delete and
493
+ move (only the containers above the change are recomputed) and rebuilt when
494
+ a storage is opened; no hash is in the snapshot. Decided in Lean
495
+ (`L4Factoidal/LWS/Merkle.lean`, `MerkleHttp.lean`), with theorems
496
+ `MkState.dig_eq_recompute` (the kept digests equal a recomputation from the
497
+ store) and `change_reaches_ancestor` (a change reaches every container
498
+ above it, with collision-freedom of SHA-256 as a stated hypothesis).
499
+ Cost (2026-10-21, wasm in Node): a write into a container of 2,000 members
500
+ takes about 6.3 ms with the option on against 0.9 ms off (linear in the
501
+ members of each container above the write); a reopen of 2,000 resources
502
+ with the hashes rebuilt takes 246 ms.
503
+
504
+ ### Performance
505
+
506
+ - LWS and Solid PUT and GET of a large body
507
+ (https://github.com/danbri/factoidal/issues/903): one 100,000-triple
508
+ N-Triples body (7.7 MB) took about 17 s to PUT and the GET stopped the
509
+ engine with out of memory; now the PUT takes 85 ms and the GET 40 ms
510
+ (`npm run test:costs`). The cause was the FNV-1a digest of the entity tag,
511
+ computed in `Nat`: on wasm32 each byte made heap numbers. It now runs in
512
+ `UInt32` (theorem `fnv1a32_eq_spec`: same value as the `Nat` definition).
513
+ Peak memory of the 100,000-triple PUT and GET fell from 2,640 MiB to
514
+ 288 MiB. Scaling check: `node tests/lws/scaling.mjs`.
515
+
516
+ ### Fixed
517
+
518
+ Seven defects of `lws-serve` that the Touchstone LWS conformance harness found
519
+ (https://github.com/danbri/factoidal/issues/911; record:
520
+ `docs/designissues/2026-10-21-lws-touchstone-run.md`). All are decided in
521
+ Lean (`formal/lean4/L4Factoidal/LWS/`):
522
+
523
+ - A POST or PUT with no content and no `Content-Type` is accepted
524
+ (`Operations.writeMediaType`). Before, it answered 400, so the draft's own
525
+ container-creation example (`POST`, `Content-Length: 0`, the
526
+ `lws#Container` type link) failed. An empty data resource made this way
527
+ has type `application/octet-stream`. Content without `Content-Type` still
528
+ answers 400.
529
+ - The Turtle form of a container uses `http://schema.org/size`, as the
530
+ `application/lws+json` context and the core draft's examples do. Before,
531
+ it used `https://schema.org/size`, so the two forms were not RDF
532
+ isomorphic. The WG's `lws10-vocab/vocabulary.yml` declares
533
+ `https://schema.org/`; the conflict is recorded in `Binding.containerTurtle`.
534
+ - A container's `ETag` carries the FNV-1a digest of its listing in the
535
+ negotiated media type (`Binding.containerTag`). Before, it was the
536
+ container's clock only, and a host with a real clock sets the clock to
537
+ the current second, so two changes in one second gave one strong tag.
538
+ `Last-Modified` is unchanged.
539
+ - The `lws#storage` link of a 401 and the default token audience are the
540
+ storage description IRI `<base>.well-known/lws-storage`, the IRI every
541
+ other response links and the description's `id` (`Authorization.storageIri`).
542
+ Before, both were the storage root `<base>`.
543
+ - `If-Unmodified-Since` is evaluated for every method (412 when the resource
544
+ changed after the date), and `If-Modified-Since` and `If-Unmodified-Since`
545
+ are compared as instants in all three HTTP-date formats of RFC 9110
546
+ §5.6.7 (`HttpDate.parseHttpDate`; theorem `unixOfCivil_civilOfUnix`).
547
+ Before, `If-Unmodified-Since` was ignored and `If-Modified-Since` matched
548
+ only the exact `Last-Modified` text.
549
+ - A POST names its new resource with the first free name of the hint (or
550
+ `res<clock>`), `-1`, `-2`, ... (`Operations.childPathFor`, theorem
551
+ `childPathFor_fresh`). Before, a clash got the clock as its one suffix,
552
+ and under a real clock the third POST to one container in one second
553
+ REPLACED the second resource.
554
+ - The Turtle form of a container gives a contained container no
555
+ `dcterms:modified`: the `application/lws+json` context defines
556
+ `modified` for a DataResource only, so the JSON item's `modified` of a
557
+ container is no triple, and the two forms must be RDF isomorphic.
558
+ - `factoidal lws-serve --page-size N` passes `N` to the engine's
559
+ `pageSize`.
560
+ - Result: Touchstone (221 tests) runs with no failure, open 70 pass, 0
561
+ fail, 151 inapplicable; with authorization 146 pass, 0 fail, 75
562
+ inapplicable.
563
+
564
+ Breaking: an LWS access token must name the storage description IRI in
565
+ `aud` (or the `--audience` given). A token for the storage root IRI is now
566
+ refused with 401 `invalid_token`.
567
+
3
568
  ## 0.11.0 — 2026-10-21
4
569
 
5
570
  No RDF, SPARQL, datatype or IRI decision is made in JavaScript any more