Skip to content

Decisions ​

The architecture decisions behind kernl, one entry per D-NNN. Every D-NNN mentioned in these docs links to its entry here, at the anchor #d-nnn. The log is DECISIONS.md at the root of the source repository, which is private during the alpha; this page includes that file verbatim when the docs are built, so it is never a second copy that can drift.

Architecture decisions for kernl, newest last. Each entry records what was decided, why, and what it would take to reverse it. A decision stays here after it is reversed; it gets a Superseded by line instead of being deleted.

Format: D-NNN · title → Context, Decision, Consequences, Reversal cost.


D-001 · kernl composes kehto; it does not fork it ​

Context. kehto publishes @kehto/{acl,firewall,runtime,shell,services,nip,wm} as the unopinionated NIP-5D toolkit. kernl is the opinionated shell. Two of kehto's seams are narrower than kernl wants: ShellAdapter does not forward getFocusContext, onFirewallEvent or firewallPersistence to the runtime, and createRuntime hardcodes the ACL default policy to 'permissive'. The transport internals (createNip5dTransport, createShellReadyState) are not exported, so kernl cannot assemble its own bridge from kehto parts.

Decision. kernl depends on the published @kehto/* packages at pinned minor ranges and uses createShellBridge unchanged. Behaviour kehto does not expose is built in front of the bridge, not inside a fork:

  • Focus-aware rate limiting runs in @kernlize/guard using the pure @kehto/firewall evaluate() engine with focus supplied by kernl's window manager. The runtime's built-in firewall is additionally tightened through bridge.runtime.firewallState.
  • A restrictive grant posture is produced with capabilities.resolveEnvironment, which narrows the advertised domains per applet identity until the user grants them, plus explicit aclState.grant calls. The runtime's permissive default is never reached for a domain the user has not granted.

Consequences. Four upstream gaps are filed against kehto/web rather than patched locally (kehto/web #397, #398, #399, #400): (1) forward the firewall hooks through ShellAdapter; (2) let the host choose the ACL default policy; (3) offer a hook that can hold or drop port traffic, since onNappletMessage runs before dispatch but its answer is ignored; (4) let the runtime firewall apply an unfocused multiplier, which needs (1)'s getFocusContext and a setter for unfocusedMultiplier; until then the runtime treats every window as focused. When any of them lands, the corresponding kernl layer shrinks (see D-012).

Reversal cost. Low. Both local layers sit behind interfaces in @kernlize/guard and @kernlize/core and can be deleted when upstream provides the hook.

Superseded in part by D-020. All four gaps are closed in @kehto/shell 0.32.0, @kehto/runtime 0.28.0 and @kehto/firewall 0.7.0. The restrictive posture now comes from aclDefaultPolicy, and the runtime firewall receives focus, the profile's multiplier and burst guard, and an event sink. The focus-aware firewall in @kernlize/guard stays, for the reasons D-020 gives.

D-002 · applesauce v7 is the only event layer ​

Context. kehto's reference runtime (Paja) uses nostr-tools SimplePool. kernl's first opinion is that applesauce is the event layer.

Decision. applesauce-core (EventStore), applesauce-relay (RelayPool), applesauce-signers, applesauce-accounts (AccountManager) and applesauce-loaders are the only libraries kernl source imports for events, relays, signing and accounts. nostr-tools is a peer dependency of @kernlize/core, because kehto and applesauce require it; kernl source does not import it. kehto's RelayPoolLike, RelayPoolServiceOptions and DmRelayPool contracts are satisfied by thin adapters over one shared RelayPool in @kernlize/core/applesauce.

Consequences. One pool, one store, one account manager per shell. A guard test fails the build if nostr-tools is imported from kernl source.

Reversal cost. High by design. The adapters are small, but the opinion is the point.

D-003 · Every kehto mitigation ships on, in its strict profile ​

Context. Opinion three in SOUL.md.

Decision. @kernlize/guard exposes three profiles, strict, balanced and relaxed. The hosted shell boots in strict. The target is that the user loosens the profile per applet; in this draft the user changes one shell-wide profile from the guard panel (see Status below), never a developer flag in the hosted shell. An embedding host may choose the starting profile (KernlOptions.guard.profile), which changes limits only; the sandbox, the CSP, the frame lock and consent are fixed. The mitigations and their defaults are listed in docs/concepts/guard.md and surfaced in the UI as a readable list with reasons.

Consequences. Some applets will hit limits in strict. The guard emits an event for every rejection and deferral so the user can see what happened and decide.

Status in the first draft. The profile is shell-wide and not persisted: GuardFacade.setProfile rebuilds the single pipeline, and windows already open keep the runtime rates installed at mount. Per-applet, remembered loosening is the target this decision describes and is tracked in docs/roadmap.md. The default remains strict on every boot, so the gap errs toward the user. kehto's experimental mitigation experimental.nestedContextLock, released in @kehto/shell 0.30.0, is enabled for every applet, and kernl adds its insertion lock on top (D-014).

Reversal cost. A one-line profile change for a user; a SOUL.md amendment for the project.

Superseded in part by D-020. A profile change now reinstalls the runtime rates of every open window and sets the runtime's unfocused multiplier and startup burst guard, so open windows no longer keep the rates they were mounted with.

D-004 · Four packages, one app, one docs site ​

Context. The repository needs a shape that lets docs, tests and publishing stay per-package without forcing the shell UI into a framework-free constraint.

Decision.

WorkspaceNameRoleFramework
packages/guard@kernlize/guardIngress flood control, visibility gate, focus-aware firewall, CSP profile, nested-frame lock, mitigation descriptions. Pure.none
packages/messaging@kernlize/messagingMessaging provider contract, registry, composite NAP-DM adapter, provider descriptions. Pure.none
packages/bridges@kernlize/bridgesLocator parsing, bridge contract, registry, resource resolver with SSRF policy. Pure.none
packages/core@kernlize/coreComposition root: applesauce adapters, applet resolution and mount plan, consent summaries, layouts, persistence. Browser-agnostic.none
apps/web@kernlize/webLanding site at / and the shell at /app/.Astro 7, Svelte 5 islands
docs@kernlize/docsDocumentation at /docs/.VitePress

Published packages have zero framework dependencies, matching kehto's rule. The app is free to use Svelte.

Consequences. TypeDoc runs over the four packages. Each has a docs/packages/<name>.md page with a version row, enforced by scripts/audit-docs.mjs.

Reversal cost. Medium. Merging packages is cheap; splitting the app later is not.

D-005 · Behaviour is specified in Gherkin and executed by vitest ​

Context. The requirement is BDD with full coverage. kehto and napplet both use plain vitest.

Decision. Every package has a features/ directory of .feature files run by quickpickle inside vitest, with step definitions in features/steps/. Coverage thresholds are 100% for statements, branches, functions and lines over packages/*/src. Plain *.test.ts files are allowed only for property-style checks that do not read as behaviour; they do not substitute for a feature.

Consequences. A behaviour without a scenario is not merged. Coverage is enforced in CI through vitest run --coverage.

Reversal cost. Low mechanically, high culturally.

D-006 · The public site never names a protocol ​

Context. Requirement five: the website is gorgeous and does not say the protocol's name.

Decision. apps/web output is scanned by scripts/check-public-vocabulary.mjs for a denylist (nostr, nip-, napplet, relay, pubkey, npub, nsec) and the build fails on a hit. The denylist applies to the landing pages and the shell UI copy. It does not apply to docs/, which is written for developers and must name things precisely.

Consequences. UI copy says "applet", "account", "network", "provider", "guard". Developer-facing terms live in the docs.

Reversal cost. Low.

D-007 · The site is agent-readable by construction ​

Context. Requirement: an agent-readiness score of 84 or higher, llms.txt, WebMCP where sensible.

Decision. The Astro build emits /llms.txt, /llms-full.txt, a Markdown twin for every page with <link rel="alternate" type="text/markdown">, /robots.txt that allows the common AI crawlers and references the sitemap, JSON-LD (WebApplication, SoftwareSourceCode), Open Graph and canonical metadata, and semantic forms with WebMCP declarative attributes plus a guarded navigator.modelContext.registerTool registration. The docs build emits /docs/llms.txt and /docs/llms-full.txt. scripts/audit-agent-readiness.mjs checks the built artifact for every item.

Consequences. Agent discovery is part of the build contract, not a later task.

Reversal cost. Low.

D-008 · Repository and deployment ​

Context. The GitHub organisation is kernlize; the repository is kernlize/web. No custom domain is decided.

Decision. URLs in package metadata point at https://github.com/kernlize/web. The Pages artifact is built with a configurable SITE_BASE (default /) and SITE_URL; the Pages workflow reads both from repository variables and falls back to /web/ and https://kernlize.github.io/web/. When a domain is chosen, only the variables change. See D-013 for why production should be a domain root.

Consequences. Absolute URLs in llms.txt, sitemap, canonical tags and JSON-LD are derived from SITE_URL at build time.

Reversal cost. Low.

D-009 · Messaging providers describe themselves; the shell does not rank them ​

Context. Opinion five.

Decision. MessagingProvider.properties is a fixed, truthful set of fields (forward secrecy, post-compromise security, metadata protection, same-client requirement, group support, specification reference). The shell renders these fields verbatim in the provider picker. There is no "recommended" flag. The first draft ships three providers: private (gift-wrapped private messages), legacy (the original direct-message format, marked as metadata-exposing), and ratchet (double-ratchet sessions, marked experimental and available only when its runtime is supplied).

Consequences. Adding a provider is a package change plus a docs page. Removing the ranking is not possible because there is none.

Extended by D-027. The providers are now kept in an order, and it is the user's: the shell ships a starting order and recommends none.

Reversal cost. Low.

D-010 · Bridges are named by what they do ​

Context. Opinion four.

Decision. @kernlize/bridges defines a Bridge with a user-facing label (downloads, mail) and a list of locator schemes (magnet:, nzb:, slsk:, mailto:). The first draft ships a complete HTTP bridge with SSRF policy and four parsers with backend interfaces (TorrentBackend, NzbBackend, SoulseekBackend, MailBackend). Without a backend, a bridge reports unavailable and the shell shows that honestly. Backends are out of scope for this draft and are tracked in docs/roadmap.md.

Consequences. The bridge contract is exercised end to end by the HTTP bridge, so later backends plug into a tested path.

Reversal cost. Low.

D-011 · Preview mode is a product feature, not a test double ​

Context. The shell needs to be usable without an account, a network or a published applet: for people evaluating it, for the Playwright suite, and for the landing page's "open" form when nothing real is reachable.

Decision. apps/web/src/shell/preview-kernl.ts implements the same Kernl contract as @kernlize/core using the real @kernlize/guard, @kernlize/messaging (memory backend with a scripted correspondent) and @kernlize/bridges packages, a real applesauce AccountManager, and two sample applets whose srcdoc carries the guard's CSP and exercises consent, notifications, flooding and link opening. ?preview=1 selects it; the live path falls back to it only when createKernl is unavailable, and says so in a notice.

Consequences. Preview behaviour is held to the same vocabulary and accessibility rules as the live shell, and to the same rules where it stands in for it: it asks before a write only for the kinds @kernlize/core's isDestructiveKind accepts and describes them with describeChange, its guard holds nothing back and stops an applet only by quarantine (D-012), it starts strict on every load, and its sample applets remove WebRTC the way kehto's prelude does. Sample applets are the first place a new mitigation is demonstrated.

Reversal cost. Low; the preview is one module behind the contract.

D-012 · Guard enforcement is layered: the runtime firewall refuses, kernl's guard refuses through it, quarantine stops the triggering message ​

Context. kehto's bridge owns the MessagePort an applet speaks over, and exposes applet traffic to the host only through ShellAdapter.onNappletMessage. kehto's dispatchEnvelope calls that hook synchronously for each envelope, before it resolves the window's session and dispatches the envelope to the runtime, and ignores the hook's answer. The runtime then evaluates its own firewall, in the same synchronous call, before handing the envelope to a service. kernl cannot sit between the port and the runtime (D-001).

Decision. Enforcement is split by what each layer can do:

  • Refused before dispatch by kehto's runtime firewall: rates and the startup burst. When an applet is mounted, kernl installs the guard profile's per-class rates and global rate in the runtime firewall (applyRuntimeFirewallRules, translating op classes through RUNTIME_OP_CLASSES), with exceed action block. It sets no policy, because kehto's allow policy makes evaluate() skip every rate, matcher and burst check. The runtime supplies initElapsedMs, so kehto's own startup burst guard also applies at kehto's default: more than 20 operations in a window's first 3 s are blocked. A refused envelope is answered with an error starting firewall:.
  • Refused before dispatch through the runtime firewall: everything kernl's own guard will not let through. kehto's matchers cannot see the size of a download or query request, because the runtime measures only envelope.event, and the runtime knows nothing of focus or visibility. kernl's ingress guard, focus firewall and hidden-window check judge every envelope inside onNappletMessage with the shell's focus and visibility, and GuardPipeline.observe returns a disposition. For every defer or reject (an envelope over its size cap, size:<class>; a burst above kernl's per-second rate, flood:burst or flood:queue-full; a request over the smaller unfocused budget, the focus firewall's rate:<class>; or any non-control envelope from a window that is not visible, hidden:<class>), createKernl calls refuseCurrentDispatch, which sets the applet's runtime policy to deny for that one synchronous dispatch and restores the previous policy in a microtask, before the next port message can arrive. The applet receives a firewall: error and can retry; nothing it asked for is handled, and the user sees the guard event. Nothing is held: a burst envelope is not queued, and a hidden window's request is refused rather than deferred, so the applet retries once visible. Control envelopes (classifyEnvelope class control: shell.*, inc.*, theme.*, closes and the like) are never refused, so a window always stays controllable.
  • Quarantined: floods and hidden downloads past the allowance. A quarantine disposition revokes the applet's binding inside that same callback, so kehto finds no session for the window, the message that crossed the limit is not dispatched, and the applet is closed.

The window-level hello is the one message kernl can hold or drop before kehto sees it.

Consequences. A hidden applet's requests fail instead of waiting: the gate cannot hold a port envelope, so the applet must retry once visible, and a hidden download past the allowance still quarantines. A hook that let the shell defer instead of refuse is kehto/web#399. The runtime firewall applies no unfocused multiplier, because kehto does not forward focus to it (D-001, gaps 1 and 4); the profile's multiplier is applied by kernl's focus firewall, whose rejections are refused through refuseCurrentDispatch. The runtime's refusals reach the applet but not the host, because ShellAdapter does not forward onFirewallEvent; the guard panel shows kernl's own count of the same rates. Rates are installed at mount, so a window keeps the rates it was mounted with when the profile changes. Earlier drafts of this entry said the rates were refused while kernl also set the allow policy that bypassed them, and until 2026-11-04 focus tightening, bursts and hidden activity were reported but still handled; docs/alpha-status.md records both. The upstream requests that would give kernl a true pre-dispatch hook are recorded with D-001's gaps.

Reversal cost. Low; the pipeline sits behind createGuardPipeline and shrinks when kehto exposes a hook that can hold or drop an envelope. refuseCurrentDispatch goes when kehto offers that hook and its runtime can measure the envelopes and focus it cannot see today.

Superseded in part by D-020. kehto now offers that hook. Port traffic is decided by ShellAdapter.filterNappletMessage: what kernl's guard will not let through is dropped rather than refused, a hidden window's requests are held and released when it is visible, and refuseCurrentDispatch is deleted. The runtime firewall now applies the unfocused multiplier and reports its refusals to the guard log. The size, burst, focus and quarantine layers described above remain kernl's.

D-013 · The site is served at a domain root ​

Context. D-007 sets an agent-readiness score of 84 or higher. Measured with amiagentready on 2026-11-03, the scanner works on the origin: it scores the domain's homepage whatever path it is given, and reads robots.txt and llms.txt from the domain root. The scoring is reproducible from its response: each scored check contributes its weight, pass at full, warn at half, fail at zero.

Measurement. The Pages artifact served at the root of a temporary public origin, through scripts/serve-pages.mjs, scored 92 (12 pass, 2 warn, 0 fail; WebMCP detected). The two warns are "online booking" (kernl has none) and "schema coverage" (asks for local-business types such as opening hours and ratings, which do not apply). The same origin hosts /docs/, so the one score covers the website and the docs.

Decision. Production serves the Pages artifact at the root of its own domain, and the host answers Accept: text/markdown with the page's Markdown twin (Vary: Accept), as scripts/serve-pages.mjs does. The Pages workflow reads SITE_URL and SITE_BASE from repository variables, so a custom domain needs no code change.

Consequences. Served from the project subpath kernlize.github.io/web/ (D-008's default), the score drops to about 82 because the origin has no robots.txt or llms.txt. A static host without content negotiation scores 90 at a root. Both regressions are visible in docs/how-tos/deploy.md.

Production, 2026-11-03. The artifact is published to the public kernlize/kernlize.github.io repository (built output only; this repository stays private) and served at https://kernlize.github.io/. Live amiagentready score: 90, no failing checks; the site and /docs/ share the score because the tool grades the origin. The browser suite, including the public-vocabulary walk-through, passes against the live URL (PLAYWRIGHT_BASE_URL=https://kernlize.github.io).

Publishing is manual, by the maintainer's choice (2026-11-03). pnpm site:publish builds, runs the vocabulary and agent-readiness gates, and pushes .pages/ to the site repository. CI builds and audits on every push; it would publish only if a scoped SITE_PUBLISH_TOKEN were added, and none is.

Superseded in part by D-019 (2026-11-04). Production moved to https://kernl.run/ on bunny.net, and publishing is automatic on every push to main. The domain-root reasoning above still holds.

Reversal cost. Low; only deployment configuration.

D-014 · Applets cannot open nested frames: kehto's nested-context lock, plus kernl's insertion lock ​

Context. An independent review showed that applet code could create a child <iframe srcdoc> whose fresh realm still had a working RTCPeerConnection, which gathered a server-reflexive candidate from a public STUN server. kehto's prelude removes the peer-connection globals from the applet's own realm only, and CSP cannot help: about:srcdoc and about:blank children involve no fetch, so frame-src 'none' and child-src 'none' never apply, and no shipped directive governs WebRTC. The probe in tests/e2e/applet-network-sealed.spec.ts, run against a srcdoc built by @kehto/shell 0.29.0 alone, reproduces it in Chromium: every scripted route it tries (createElement, createElementNS, innerHTML, outerHTML, insertAdjacentHTML, createContextualFragment, DOMParser with importNode and adoptNode, createDocument, createHTMLDocument, document.write) produced a child realm that reported host and srflx candidates.

Decision. Opinion 3 in SOUL.md (the user outranks the applet developer) is met by enabling kehto's own mitigation. planMount passes experimental: { nestedContextLock: true } to prepareNappletSrcdoc from @kehto/shell 0.30.0 for every applet (D-003):

  • kehto renders require-trusted-types-for 'script'; trusted-types default into the applet's CSP meta, and its prelude installs a Trusted Types default policy that refuses frame markup at every HTML string sink and pins Document.prototype.createElement, createElementNS and DOMImplementation.prototype.createDocument non-configurable to refuse frame tag names. Both throw TypeError('kehto: napplets may not create nested browsing contexts').
  • planMount runs kehto's assertNappletHasNoFrame on the verified bytes before they become a srcdoc, and maps its refusal of static iframe, frame, frameset, object, embed, portal or fencedframe markup to denied:nested-frame.

kehto's lock guards creation only, and a second independent review (2026-11-03) found four creation routes past it, each producing a child frame with a working peer connection that reached a public STUN server: a customized built-in element constructed with new (constructors skip createElement), XSLT transformToFragment, and an XHTML-parsed element with a namespace prefix moved in with importNode or adoptNode (kehto's string-sink pattern does not match prefixed tags). Creation routes are open-ended, so kernl adds a lock on insertion on top of kehto's: a frame gets a realm only when connected, and connecting goes through a small set of methods. @kernlize/guard's appletInsertionLock pins appendChild, insertBefore, replaceChild, append, prepend, replaceChildren, before, after, replaceWith, insertAdjacentElement, moveBefore, Range.insertNode and Range.surroundContents on their owning prototypes to refuse, with TypeError('denied:nested-frame'), any node that is, or contains (descendants, template content, shadow roots), an HTML-namespace frame element. Node identity is read through native getters captured before applet code runs, so an element cannot disguise itself. It also refuses custom elements that extend frame elements and removes XSLTProcessor. renderInsertionLockScript emits it as <script data-kernl-insertion-lock>, and planMount inserts that immediately after kehto's prelude, before any applet script; when kehto's CSP meta and prelude are not where it expects them, the mount is refused with unavailable:frame-lock rather than run without the lock. The four routes are to be reported to kehto.

With both locks, the probe sees every creation route throw, the four bypass routes fail at creation or insertion, and thirteen insertion routes refuse an unconnected frame; it finds no child realm and no candidate in any realm, while the host page's control reaches the STUN server. The probe stays as the regression test.

Consequences. An applet that creates child frames, ships one in its markup, or registers its own Trusted Types policy (Angular, for one) is refused or breaks. That is the trade opinion 3 makes: an applet has no legitimate need for a nested browsing context inside a shell that already gives it a window. A string written to an HTML sink that merely mentions a frame tag, in text or an attribute, is refused too. Every DOM insertion in an applet walks the inserted subtree once.

Reversal. Drop appletInsertionLock, renderInsertionLockScript and the insertion in planMount once kehto guards insertion as well as creation. Turn nestedContextLock off only by reversing opinion 3 in SOUL.md.

Update to D-014, 2026-11-04. Until this date @kernlize/guard carried an attributed port of kehto's lock from kehto main (4d8a5693), which no release then carried: its own Trusted Types policy and factory pins (renderFrameLockScript), the byte scan (appletMarkupHasFrame, assertAppletHasNoFrame), and TRUSTED_TYPES_DIRECTIVES spliced by planMount into the CSP meta kehto 0.29.0 rendered. @kehto/shell 0.30.0 ships the flag, so the port is replaced by kehto's released lock as above, and those exports and AppletCsp.trustedTypes are removed. Only the insertion lock remains kernl's.

D-015 · Messaging backends are built on the account signer ​

Context. The first draft shipped every messaging provider unavailable. kehto's sealed-message adapter wants the owner's raw secret key, and kernl keeps keys only inside applesauce signer instances (SOUL.md opinion 1, AGENTS.md security posture). Browser extensions and remote signers never hand a key over at all, so an adapter that needs one would exclude most accounts and would make kernl the place a key leaks from.

Decision. @kernlize/core builds its own backends from applesauce helpers and the active account's signer:

  • createPrivateMessagesBackend({ store, pool, tiers, signer, selfPubkey, now? }) sends NIP-17 messages: the kind 14 rumour is sealed in a kind 13 event that the account signer signs, encrypting with its nip44, and the seal is wrapped in a kind 1059 event signed by an ephemeral key. The ephemeral key is generated inside an applesauce PrivateKeySigner, used for that one wrap, and dropped with the signer; it is never persisted and never leaves the class.
  • createLegacyMessagesBackend(...) sends NIP-04 kind 4 messages through signer.nip04, normalised the same way, and stays marked metadata-exposing (D-009).
  • Delivery goes to the recipient's kind 10050 inbox relays. A recipient who publishes none is reached through the sender's outbox and super tiers, because a message that is never sent protects nobody and those are the relays the user already chose. A copy is wrapped to the sender. The subscription reads kind 1059 addressed to the user from the user's own inbox relays, or the same tiers when there are none. A conversation id is the sorted participant set.
  • createKernl wires both unless KernlOptions.messagingBackends overrides them. Availability follows the active account: private needs a signer with nip44, legacy one with nip04. The reasons are sign-in-required with no active account and signer-cannot-encrypt when the signer lacks the method; ratchet keeps no-runtime (until D-016). needs-local-key-signer is gone.
  • The shell's own Messages panel decrypts the user's messages without a consent prompt. The request comes from the shell, on the user's behalf, to show the user their own messages; a prompt there would ask the user whether they may read their mail. An applet reaches messages only through the NAP dm domain, which is a grant the user gives and can revoke, and through the guard's dm:send rate.

Consequences. Messaging works with every account type that can encrypt, including ones whose key kernl never sees. A remote signer or extension may ask its own question per decryption; kernl does not suppress it. A read-only account sees both providers unavailable with signer-cannot-encrypt. The inbox fallback means a recipient without inbox relays may never see a message sent to relays they do not read; the sender sees it delivered to those relays, not read. kehto's sealed-message adapter is not used; when it can take a signer instead of a key, the backend can be swapped behind MessagingBackend without a contract change (D-001).

Reversal cost. Low; both backends sit behind MessagingBackend and KernlOptions.messagingBackends.

Superseded in part by D-027. The shell no longer has a Messages panel. Messages are shown by an applet over the dm domain; the one kernl ships starts with that grant.

D-016 · Ratchet sessions run the library's runtime on the account signer, with its secrets sealed to the account ​

Context. D-009 lists ratchet and D-015 left it no-runtime. The published nostr-double-ratchet package (0.0.183) ships NdrRuntime, which models an account (owner) and its devices. It needs four things from the host: nostrSubscribe, nostrPublish, an optional nostrSign for the owner's events, and a StorageAdapter. ownerIdentityKey (the raw account key) is optional: it is used only to sign a local device-list snapshot early and as a compatibility input the library no longer encrypts with. The owner signs one thing, the kind 37368 device list; everything else (invites, kind 1059 invite responses, kind 1060 session messages) is signed and encrypted by a device key the library's DelegateManager generates, and by per-session ratchet keys. The library writes that device key and every session's state into the StorageAdapter in the clear. kehto's createNdrDmAdapter wraps an NdrRuntimeLike, but files every session event as a message (typing notices included), never initialises or registers the runtime, and has no notion of the active account.

Decision. @kernlize/core runs the runtime without the account key and adapts it itself:

  • createRatchetMessagesBackend builds one NdrRuntime per signed-in account, with transport over kernl's pool on the user's outbox and super tiers, and signs the owner's events with the account signer from their template (the library leaves pubkey empty, which an applesauce account rejects). It calls initForOwner, ensureCurrentDeviceRegistered and republishInvite, files only kind 14 rumours into the shared conversation store, and closes the runtime when the account changes. It is the default ratchet backend and is available while the signer has nip44.
  • createSealedRatchetStorage is the library's StorageAdapter. Every value is encrypted to the account with the signer's nip44 (chunked under the 64 KiB limit) and stored under an opaque slot number; the key-to-slot map, whose key names include correspondents' public keys, is sealed the same way. The library's device and session keys therefore exist in clear only inside the library's memory, never in kernl's storage and never in kernl code. This is the reading of SOUL.md opinion 1 this decision takes: kernl keeps and persists no raw key bytes; a library that must hold its own session keys may, and what it persists is ciphertext only the account can open.
  • kehto's createNdrDmAdapter is not used, because it would file typing notices as messages and cannot follow the account; its NdrRuntimeLike shape is what kernl's backend drives.

Consequences. Ratchet sessions work with any account that can nip44, including extensions and remote signers, which may ask once per stored value per page load and on each write. Sessions are one to one; groups are not offered. Both sides must share a relay, because inbox relays are not consulted. The conversation list is in memory and empties on reload, while sessions and keys persist. The first use waits up to the library's device-list discovery timeouts (10 s and 2 s). Clearing site data ends this device's sessions. Applets reach ratchet through the dm domain with ratchet:-namespaced ids, like every provider.

Reversal cost. Low; the backend sits behind MessagingBackend and KernlOptions.messagingBackends.ratchet. Swapping to kehto's adapter needs it to filter rumour kinds and follow the account.

D-017 · Magnet links download inside the shell; NZB, Soulseek and mail stay hand-off ​

Context. D-010 shipped the downloads, sharing and mail bridges as hand-offs: kernl rebuilds the address and the device's own app does the transfer. That works, but it sends the user out of kernl for the most common case, a file shared by peers. A page has exactly the network primitives the browser gives it: fetch, WebSocket and WebRTC. WebTorrent speaks BitTorrent over WebRTC data channels and finds peers through wss:// trackers, so a magnet link can download inside the page. The other three protocols cannot: NZB downloads speak NNTP to a news server over raw TCP, Soulseek is its own protocol over raw TCP to a central server and to peers, and mail needs SMTP and IMAP over TCP. A browser page cannot open a raw TCP socket, so no amount of client code inside the shell can reach any of them.

Decision. @kernlize/bridges adds createWebTorrentBackend({ client, trackers?, now?, progressIntervalMs? }). client is a structural WebTorrentLike (add(magnet), remove(torrent, { destroyStore })) or a loader that returns one, so the package stays free of dependencies and the library loads only on the first download. The backend validates the hash (invalid:info-hash), keeps only the link's wss:// trackers and appends DEFAULT_WEBRTC_TRACKERS (UDP and plain-TCP trackers are unreachable from a page), deduplicates by hash, and reports a client that fails to load or add as unavailable:<why>. Besides add it exposes jobs(), subscribe(listener), pause(id), resume(id), remove(id) and files(id), which hands finished files over as blobs; an unknown id is denied:unknown-download and an unfinished one unavailable:not-finished. Progress-only notifications are throttled to one per 500 ms. It carries no handoff trait, so describeBridge reports the transfer as kernl's own while keeping the torrent exposure: peers see the user's address, the transfer is not encrypted, and the activity is public.

createDefaultBridges takes an optional torrent backend that wins over the hand-off for the torrent bridge only; KernlOptions.torrent passes one through createKernl. The shell host page (/app/) installs webtorrent 3.0.21 at an exact version and imports its browser build lazily on the first download. The shell's torrent backend follows the user's choice in Settings, "Download in kernl" (the default where the browser has RTCPeerConnection) or "Open in an app on your device", remembered under kernl:downloads:mode:v1; BackendTraits.handoff is read on every availability check so the bridge's description follows the choice. A Downloads panel lists jobs with progress, pause, resume, remove and Save, which hands each file to the browser through a blob URL. The preview uses a client that reaches nobody and fills each download in over a few seconds.

NZB, Soulseek and mail stay hand-off. Running them inside kernl needs something that can open TCP sockets on the user's behalf: a companion service the user runs or chooses, which the shell would reach over WebSocket. That is on the roadmap, not in this draft.

Consequences. The WebRTC connections are the shell page's, never an applet's: applet frames keep the guard's CSP and kehto's prelude that removes WebRTC, and the host page's policy (frame-src 'none', no connect-src limit) already permits WebRTC and wss://. A WebTorrent client in a browser reaches only WebRTC peers, which are fewer than a desktop client's, so a link whose swarm has no browser peers may never start; the job stays "Looking for peers" and the user can switch to the device app. Downloads live in memory for the page's lifetime; closing the tab drops them.

Reversal cost. Low. The backend sits behind TorrentBackend; removing KernlOptions.torrent restores the D-010 hand-off.

D-018 · Every private-messaging protocol is a provider; group sessions run the Marmot library with its state sealed to the account ​

Context. SOUL opinion 5 asks kernl to carry any reasonable private-messaging protocol behind the one Direct Messenger contract, unranked. The NIPs index (November 2026) lists NIP-04 and NIP-17 (with NIP-44 and NIP-59) for direct messages, NIP-29 relay-based groups, NIP-28, NIP-C7, NIP-A4 and NIP-A0 for public chat, and NIP-EE, now marked unrecommended and superseded by the Marmot protocol (MLS, RFC 9420, over kinds 30443, 444 and 445). The nostr-double-ratchet draft lives outside the index (D-016). The maintained TypeScript Marmot implementation is @internet-privacy/marmot-ts (0.9.0, alpha, wire-compatible with the reference implementation, built on applesauce 7 and a vendored ts-mls). kehto's createCordnDmAdapter was considered and not used: it adapts a Cordn/ContextVM coordinator client, which kernl would have to supply, not an MLS runtime.

Decision.

  • Every private, end-to-end encrypted protocol in that inventory is a provider: legacy (NIP-04), private (NIP-17), ratchet (D-016) and group (Marmot). The concept page keeps the inventory and each protocol's status.
  • group runs MarmotClient per signed-in account. The account signer is the library's EventSigner: it signs identity proofs, key packages and invitation seals and opens invitations with nip44. The library generates and holds its MLS keys and group secrets. Every store it keeps goes through createSealedGroupStore, one value per store encrypted to the account with nip44, so SOUL.md opinion 1's key rule holds as in D-016. The network is an adapter over the shell's one pool.
  • Using the provider publishes a key package for the account, so others can invite it. That is an addition to the public record, not a replacement, so it is not a consent prompt (SOUL opinion 2); the docs say it happens.
  • A send to people reuses the group with exactly those members, else founds one. Messages are kept in memory: the library deletes per-message secrets once a message is read, so history cannot be read again, and keeping it sealed is roadmap work.
  • NIP-29 is not a provider: its groups are readable by the relay that hosts them, so it is not private messaging in the sense opinion 5 means, and a group addressed by relay and id does not fit the dm recipient model. The public chat NIPs are out of scope.

Consequences. Group sessions work with any account that can nip44, including extensions and remote signers, which are asked once per sealed store per page load and on every write. An account is one member of a group, as the library enforces, so only one of a person's devices joins. Applets reach group like every provider, through the dm composite as group:<group id>. The vocabulary check also refuses "marmot" and "mls" on the public site.

Reversal cost. Low; the backend sits behind MessagingBackend and KernlOptions.messagingBackends.group. A host can replace it, or drop the provider by registering its own registry.

D-019 · The site is served at kernl.run by a bunny.net static site, deployed on every push to main ​

Context. D-013 served the artifact at the root of kernlize.github.io from a public repository holding only built output, published by hand. The project now has its own domain, kernl.run. bunny.net static sites keep each deploy as an immutable directory in a storage zone and point a pull zone's edge rule at the live one, so publishing and rollback are a retarget and a cache purge. The same account already serves other sites from the maintainer's projects, deployed from GitHub Actions.

Decision. The bunny.net site kernl serves .pages/ at https://kernl.run/. .github/workflows/pages.yml (Publish site) builds and audits on every push to main and deploys with BunnyWay/actions/deploy-site, pinned to the deploy-site@0.1.2 commit, which records each deploy in the repository's Environments. Deploys are never cancelled in flight. The workflow needs one secret, BUNNYNET_API_KEY, and reads the variables BUNNY_SITE, SITE_URL and SITE_BASE; scripts/setup-bunny-secrets.sh sets all four from the bunny CLI login without prompting, so it is safe to re-run. pnpm site:publish deploys the same artifact from a checkout. The source defaults (SITE_URL and the absolute links in copy) name https://kernl.run/.

Consequences. Without the secret the workflow builds, audits and stops with a notice, as before. Rolling back is bunny sites deployments publish --previous --force. The account API key in the secret can manage every bunny.net resource on the account, not only this site; bunny.net offers no narrower key for site deploys. The pull zone does not yet answer Accept: text/markdown (D-013's 92 needs a middleware Edge Script implementing scripts/serve-pages.mjs's rule), so the expected score at kernl.run is 90 until then. kernlize.github.io is no longer updated and can be retired.

Reversal cost. Low; only deployment configuration and the default origin.

D-020 · kehto's host seams replace kernl's workarounds; kernl's guard still decides first ​

Context. D-001 recorded four kehto gaps (kehto/web #397, #398, #399, #400) and D-012 built kernl's enforcement around them. kehto/web PR #409 closed all four. @kehto/shell 0.32.0 forwards getFocusContext, onFirewallEvent and firewallPersistence through ShellAdapter, takes aclDefaultPolicy, and adds filterNappletMessage, which answers dispatch, drop or defer for each port envelope, with ShellBridge.releaseDeferred(windowId) and a new revocation reason, deferred-overflow, for a window holding more than MAX_DEFERRED_MESSAGES (1024). @kehto/runtime 0.28.0 lets a policy the host sets replace the stored one on load. @kehto/firewall 0.7.0 adds setUnfocusedMultiplier and setBurstGuard, also on FirewallStateContainer. A second change is pending: kehto/web#336 makes the firewall a shared approval authority and moves rate limiting into a separate runtime budget. On a host that configures permissions it ignores setUnfocusedMultiplier, setBurstGuard, onFirewallEvent and firewallPersistence, reads only focused from getFocusContext, and keeps aclDefaultPolicy, filterNappletMessage and releaseDeferred unchanged.

Decision. kernl depends on the released packages: @kehto/shell ^0.32.0, @kehto/runtime ^0.28.0, @kehto/firewall ^0.7.0, @kehto/acl ^0.20.3 and @kehto/services ^0.30.1, which requires @napplet/core 0.38 and @napplet/nap 0.37. It builds first on the seams that survive #336:

  • Port traffic is decided before the runtime sees it. createShellAdapter passes filterNappletMessage, which runs GuardPipeline.observe and returns kehto's decision. refuseCurrentDispatch and kernl's use of onNappletMessage are deleted. What kernl's guard will not let through is dropped: an oversized download or query, a burst above the per-second rate, a request over the focus firewall's budget, and the envelope that triggers a quarantine. A dropped request gets no reply, which is kehto's documented spec gap (NAP defines no answer for a request a host drops); before, the applet got a firewall: error.
  • Hidden windows are held. A non-control envelope from a window that is not visible is deferred, reported as a defer guard event hidden:<class>, and counted against deferQueue; past it, it is dropped with defer:queue-full. When the window becomes visible the pipeline calls onRelease, and kernl calls bridge.releaseDeferred(windowId); kehto dispatches the held envelopes in order through the ACL and the runtime firewall. A hidden download past hiddenResourceAllowance still quarantines, and kehto discards what the window held. deferQueue (200 in strict, 600 in relaxed) is below MAX_DEFERRED_MESSAGES, so kernl drops before kehto would revoke; deferred-overflow is still handled the way every revocation is, by removing the frame. kehto queues every later envelope behind held ones, so once a hidden window holds anything its control requests wait too.
  • The ACL is restrictive. The adapter sets aclDefaultPolicy: 'restrictive'. KernlGrants.ensure grants the baseline and what the user allowed, and revokes only what the applet's stored entry holds beyond that (a domain the user took back, or an entry kept from the permissive posture), instead of writing every capability. The environment resolver still narrows what is advertised. Nothing is granted until the user grants it.
  • The runtime firewall knows focus and the profile. getFocusContext returns { focused } from the pipeline's visibility; it does not send msSinceFocusGain, which #336 ignores. On boot and on every profile change, applyRuntimeFirewallProfile sets the profile's unfocusedMultiplier and burstGuard ({ windowMs: 1000, maxOps: ingressBurst, action: 'block' }) and kernl reinstalls the rates of every open window. The runtime's startup burst is now the profile's, 8 operations in a window's first second in strict, instead of kehto's default of 20 in 3 s.
  • Runtime refusals reach the guard log without the message. createRuntimeFirewallHooks turns each onFirewallEvent into a GuardEvent (rate:<class>, flood:startup, or runtime:<ruleId>) and drops the applet message the runtime attaches, so nothing the applet sent reaches the log. It keeps the runtime firewall's configuration under kernl:firewall:v1 in the host's storage. These report only what the runtime itself refuses: the startup burst, and held envelopes over budget at release. Everything kernl decides is reported by kernl's own layers, so the log does not depend on these hooks.
  • The seams #336 retires live in one module. Every call into the runtime firewall's configuration (setRateLimit, setGlobalRate, setUnfocusedMultiplier, setBurstGuard, persist) and both hooks are in packages/core/src/guard/runtime-rules.ts.

What kernl still puts in front of kehto, and why. The ingress guard, the visibility gate and the focus firewall stay. kehto's runtime measures only an envelope's event, so size caps on downloads and queries are kernl's. It knows nothing of visibility, so holding hidden windows and the hidden download allowance are kernl's. Flood quarantine is kernl's. The window-level hello still passes the pipeline before bridge.handleMessage. The focus firewall now overlaps the runtime, which applies the same rates and multiplier, and it stays for three reasons: it has a separate budget for deletions, which the runtime counts as relay:write; its matchers can use msSinceFocusGain, which #336's budgets ignore; and once #336 lands a configured host gets no runtime event for a stored deny and only onRateLimit for budgets, so kernl's own decisions must stay a complete source of guard events.

When kehto/web#336 lands. In runtime-rules.ts, applyRuntimeFirewallRules and applyRuntimeFirewallProfile become one call to runtime.permissions.setRateLimit, built from the current rateLimit; onFirewallEvent becomes onRateLimit; firewallPersistence and kernl:firewall:v1 go. getFocusContext, aclDefaultPolicy, filterNappletMessage and releaseDeferred stay as they are. kernl will also have to decide how admission approvals meet the restrictive ACL, which #336 treats as a ceiling.

Consequences. An applet whose request kernl drops waits for a reply that does not come, and has to time out. Because the focus firewall counts the same rates before the runtime does, a request over a class budget is normally dropped by kernl rather than refused by the runtime with a firewall: error; the runtime's own refusals are met mostly in a window's first second and for held requests released over budget. A hidden applet no longer has to retry. Held requests meet the runtime firewall at release, at the window's focus at that moment. A real applet that sends more than 8 operations, control included, in its first second under strict has the excess refused by the runtime with firewall: errors. @kernlize/core drops refuseCurrentDispatch and changes GuardPipeline.observe and ShellAdapterDeps, which is a minor release on 0.x.

Reversal cost. Low. The decisions sit behind GuardPipeline.observe and createShellAdapter; the runtime firewall calls sit in one module.

D-021 · Registry scopes are explicit; package versions have one authority ​

Context. npm belongs to @kernlize, while JSR belongs to @kernl. A shared name cannot address both registries directly.

Decision. Workspace manifests, source imports and Changesets use @kernlize/*. npm ships the existing ESM build and declarations. JSR publishes a generated TypeScript workspace named @kernl/*, with relative source imports resolved and internal imports mapped to the corresponding JSR packages. Its versions come from package.json; external dependency versions come from the installed lockfile. The same source APIs, including npm-valued PACKAGE_NAME constants, ship to both registries.

Consequences. There is no second set of versions to synchronize. CI checks actual packed npm consumers and runs a JSR publish dry-run. Registry owners must configure publication rights separately; the release guide records those settings. Only release.yml publishes, after validation; retries skip existing versions.

Reversal cost. Low before publication: revert the scope migration and release tooling. After publication, existing consumers depend on these names; maintain them or publish a migration release.

D-022 · Window management is one replaceable interface; layouts are a registry; both live in the core behind a light entry ​

Context. How a shell manages its windows is the opinion most likely to be disagreed with: a tiling desktop, tabs, workspaces and a single-applet kiosk are all reasonable, and none is safer than another. kernl's shell had that opinion spread through a Svelte store that hardcoded two modes, floating and tiled, and carried its own copy of the dwindle arrangement, while @kernlize/core exported four LayoutStrategy values the shell never called. A developer could change the arrangement only by editing the store, and could not replace the rest (cascade, clamping, focus, stacking, minimising, the narrow-screen mode) at all. One thing in that store is not a matter of taste: what it tells the guard about each window's visibility and focus, which decides whether an applet's requests are held and how large its budget is.

Decision.

  • @kernlize/core gains a LayoutRegistry (createLayoutRegistry, createDefaultLayouts) and a WindowManager interface with kernl's own implementation, createWindowManager. The manager holds no DOM: a shell feeds it events and draws from the state it publishes on state$. The shell's two hardcoded modes become the registry's four layouts, and the shell's copy of dwindle is deleted in favour of createDwindleLayout.
  • The tiling strategies take a margin separate from gap, so the shell keeps 8 px between windows and none at the desktop edge, and they round rectangle edges rather than sizes, so neighbours never overlap or leave a hole.
  • A strategy is told the front-most window is the focused one. Real focus drops to nothing whenever the person opens one of the shell's panels, and an arrangement that follows focus would reshuffle each time.
  • The pieces are also exported from @kernlize/core/wm, a second entry that imports only rxjs. The shell loads the core on demand, so the preview (D-011) starts without it, and it needs its window manager before that; importing it from the main entry would pull the network stack into the first screen.
  • The shell app decides its window management in one file, apps/web/src/shell/windows.ts: createShellLayouts and createShellWindowManager. WindowStore shrinks to which applet is in which window, and knows the manager only through the interface. The chosen layout is stored by id under kernl:layout:v1; tiled, which earlier builds stored, reads as dwindle.
  • The interface carries one fixed obligation, written on the interface and in the how-to: what the manager tells the guard about a window must be true. Everything else is the implementation's choice.

Consequences. A layout of a developer's own is one register call, and a different window manager is one function's return value; the how-to builds a tabbed one in about a hundred lines and it runs in the shell with no other change. An embedding host can use the same manager outside the shell. kernl's own manager keeps the visibility semantics the store had: a window is visible when the page is and the window is neither minimised nor stopped. It does not detect a window wholly covered by another, so on a narrow desktop the windows behind the front one are still reported visible; that is recorded in the alpha status rather than changed here, because tightening it changes what the guard holds and deserves its own decision. @kernlize/core's type declarations are emitted once per entry, so the window-management types exist in both; they are structurally identical.

Reversal cost. Low for the shell: windows.ts is the only file that names an implementation. Medium for the package: @kernlize/core/wm is a published entry point, and removing it is a breaking change.

D-023 · The landing is one screen and two paths; the long-form pages live under /about/; motion is one WebGL stage driven by a timeline library ​

Context. The site's home page had grown to six sections: what an applet gets, the five opinions, the alpha's state, the price and the questions, on top of the hero. All of it is true and none of it tells a developer what to do next. Two readers arrive ready to act and had no route: one has an application and wants to run applets inside it, the other is starting a shell. The site was also a set of separate page loads, with a fade-in on scroll as its only motion.

Decision.

  • / is three sections. A hero that fits the first screen at any size, with one sentence, the preview and a link down the page. Two adoption paths side by side, three steps each, a sample and the tutorial that carries on: "Embed kernl in a page" for an existing application, "Run the shell" for a new shell. Then the open-by-address form. The paths say plainly that both start from a copy of the source during the alpha.
  • Everything the home page used to hold stays, unchanged, at /about/, and the five pages beneath it move to /about/<page>/. The prefix is the constant ABOUT in apps/web/src/lib/site.ts. The old addresses are Astro redirects, so links to them keep working; a redirect stub gets a one-line Markdown twin and is left out of llms.txt.
  • The site's pages use Astro's ClientRouter, so a move between them replaces the page's contents without reloading the document. /app/ does not take part: it has no router, so it is always a fresh load and its frame policy is in the HTML the server sent, as the security posture requires.
  • Motion is one persistent <canvas> drawn with three.js and timed with GSAP. Behind the home page's hero it draws the guard as a rail and requests as marks that meet it: refused, held or passed. Between pages the same canvas comes to the front and a panel crosses the screen, covering the old page while the new one loads and uncovering it; the new page's content then rises into place, and later sections do the same as they scroll in (ScrollTrigger). The stage reads its colours from the theme tokens.
  • Motion is an addition, never a condition. With reduced motion there is no canvas, no curtain and nothing starts hidden. Without WebGL the hero keeps a still rail drawn in CSS and navigation is plain. Without scripts every page is whole. If the motion script fails to arrive, a timer in the page head stops hiding anything after four seconds.

Consequences. apps/web gains two runtime dependencies, three and gsap; three.js loads in its own chunk after the page is interactive, and neither is loaded on /app/. GSAP ships under its own no-charge licence rather than MIT, which is acceptable for the site and would not be for a published package. Scripts on the site's pages run once per visit rather than once per page, so listeners that outlive a page sit on the document (the theme toggle, the open form), and the root element's theme attribute is put back after every swap. The header offers two links on the landing and the six long-form pages under /about/.

Reversal cost. Low. Removing <ClientRouter /> and <Stage /> from Site.astro returns the site to plain page loads with nothing else to change; moving the pages back is a rename and the redirect list.

D-024 · Outside a secure context the shell fills in Web Crypto from audited pure-JS primitives ​

Context. kernl is meant to be reachable from overlay networks: an onion service, or a FIPS mesh address, served over plain HTTP because the network already authenticates and encrypts the path. A browser treats such a page as an insecure context and withholds crypto.subtle and crypto.randomUUID, keeping only crypto.getRandomValues. The shell's dependencies call both: the applet runtime and its applet-side prelude for request ids, applesauce for remote-signer transports, the MLS group stack for HPKE (X25519, HKDF, AES-GCM) and Ed25519 signatures, the download client for SHA-1 and SHA-256. Over such an address the shell stopped at the sign-in screen with crypto.randomUUID is not a function.

Decision.

  • @kernlize/core/crypto exports installCryptoFallback. It adds crypto.randomUUID, built on getRandomValues, and a crypto.subtle from createFallbackSubtle, built on @noble/hashes, @noble/ciphers and @noble/curves. It never replaces an implementation the platform has, so over HTTPS it does nothing.
  • The fallback covers only what the shell's dependencies call: SHA-1/256/384/512 digests; HMAC and Ed25519 sign and verify; AES-GCM (128-bit tag) and AES-CBC; X25519 and HKDF deriveBits; Ed25519 and X25519 keys in raw, pkcs8 and jwk. Anything else rejects with NotSupportedError, as a browser does for an algorithm it lacks. Its output matches the platform's byte for byte, which the features check against Node's Web Crypto.
  • crypto-fallback.ts is the first module Shell.svelte imports, so the fallback is in place before any other module evaluates. It is its own entry for the same reason wm is (D-022): the shell loads the core on demand.
  • An applet frame is an opaque origin and inherits its parent's insecure context, so the mount plan puts a randomUUID fallback script after the insertion lock (D-014) and before any applet script. Applets get only randomUUID; an applet that needs crypto.subtle must bring its own.

Consequences. @kernlize/core gains three direct dependencies, all already in the tree through the MLS stack. Key material made by the fallback lives in JavaScript memory rather than behind the browser's non-extractable key boundary; extractable: false is honoured by exportKey but is not a hardware or process boundary. The fallback is not constant-time beyond what the noble libraries guarantee. Both are acceptable for an address the person chose to reach over an authenticated overlay, and neither applies over HTTPS, where the platform's implementation is used.

Reversal cost. Low. Deleting the import in Shell.svelte and the script in the mount plan returns the shell to the platform's Web Crypto alone.

D-025 · An address opens if any relay the shell knows can answer; the live shell is tested in a browser against a network the test owns ​

Context. An applet that opened in every other shell failed in kernl's with "Could not open the applet." The sentence is the launcher's fallback for a failure that carries none of kernl's reason prefixes, so the shell could not say what went wrong, and neither could the person reporting it. Other applets opened in the same browser minutes later, and the address opened in clean Chromium and Firefox profiles against the published build with the reporter's own relay settings, so the cause was a moment in one session and was not recovered. Looking for it found a class of failure, not one bug. fetchManifestEvent handed the lookup to the relay library with its defaults and passed its errors through: every relay failing surfaced as All relays failed; a relay that accepts a connection and hangs up is left pending by applesauce-relay 7 whatever reconnect and firstResponseTimeout say, so a lookup that met only such relays ended thirty seconds later as Timeout has occurred; a relay hint that is not a URL threw Invalid URL; and the library's default of stopping three seconds after the first relay reports the end of its stored events let a quick relay with nothing to say cut off a slower catalogue, which reads as "no such applet". The lookup also asked only the relays the address named and the apps tier, so one catalogue relay having a bad minute was enough to fail an applet that general relays also held. Past the lookup, the launcher offered a switch for every domain an applet named, including ones the shell does not serve, and switching one on made Run fail with invalid:domain; and an applet granted identity under a read-only account was told nobody was signed in. None of this was visible to the tests: every resolve scenario scripted the pool, and every browser test ran the preview, which never touches the core, a relay or a blob server.

Decision.

  • A lookup is decided by the relays that answer, in at most two rounds. The first asks the address's hints, filtered to ws:/wss: URLs, plus the apps tier. When it does not produce the manifest, the second asks the super tier. A manifest is an ordinary event that authors also publish to general relays; asking them only after the first round keeps a normal lookup on the relays the address chose.
  • Each round carries kernl's own completion rule with three clocks, MANIFEST_TIMING: settleMs (3 s) after the first manifest, for a newer release elsewhere; patienceMs (8 s) after the first relay that answers with nothing, for a slower relay that has it; timeoutMs (10 s) for a round in which nothing answers.
  • A round whose relays all refused the connection is asked once more after retryDelayMs (1 s). Public relays answer a websocket upgrade with 502 or 503 for a moment and accept the next one; on the day of the report three of the shell's default general relays were doing so from the reporter's network, which left a lookup resting on one relay, and applesauce-relay 7 fails a relay on its first refused connection whatever reconnect says. A round that ran out its clock is not asked again.
  • Every way a lookup can fail has a reason. A relay answered and none had the manifest: unavailable:manifest. No relay answered in either round: unavailable:network. The launcher has a sentence for each, and when it does fall back to the generic sentence it writes the underlying error to the console as a warning, because that case is a defect to report, not a state to explain. The raw reason still never reaches the page (D-006).
  • decodePointer finds an encoded address inside a link or a sentence, since that is how addresses are shared.
  • A domain the shell does not offer is listed in the launcher, disabled and marked, and never passed to grants.allowDomain. allowDomain still refuses it with invalid:domain for any other caller.
  • The identity service gets a getPublicKey-only view of a read-only account. getSigner stays null for it everywhere else, so writes are refused as before.
  • Tests dial. packages/core/features/live-lookup.feature runs the real relay pool and the default blob fetcher against relays and a blob server on loopback ports, including relays that are down, silent, slow, stale or hang up. tests/e2e/shell-live.spec.ts runs the live shell in Chromium and in Firefox, since the engines fail sockets and fetches differently, against relays answered through routeWebSocket and blob servers through route; relay hosts it does not name hang up, so the suite never reaches the public network. tests/e2e/shell-live-network.spec.ts opens published applets over the public network and runs only with KERNL_PUBLIC_NETWORK=1: it is the check after a deploy, not a gate that fails a commit because somebody's relay was down.

Consequences. An address fails only when no relay in its hints, the apps tier or the super tier produces the manifest, and it always ends in a sentence that names what happened. The slowest outcome is two rounds of ten seconds, reached only when every relay accepts connections and says nothing; refused connections cost one extra second and a second attempt per round. A relay that answers with nothing costs at most eight seconds of waiting for the others in its round, where it used to cost three and a wrong answer. A lookup the address's relays cannot serve is now also seen by the general relays. Applets that list an unserved domain as optional open; ones that require it open with a warning that they may not work here. ws is a development dependency of @kernlize/core, for the loopback relays.

Reversal cost. Low. The clocks are one constant and one optional argument; removing the completion rule restores the library default, and the fallback is one entry in a list of rounds.

D-026 · The shell has a store, and applets hand work to each other through intents ​

Context. The launcher offered two hard-coded sample applets and a field for pasting an address, so finding an applet meant being handed its address. Applets advertise what they are for in their signed manifests: t tags for topics, R/O for the domains they need and can use, and z/i for the roles and requests they take (NAP-INTENT). kehto ships NAP-INTENT as createIntentService, createCatalogIntentResolver and manifestToIntentCatalogEntry, and a shell offers the intent domain by answering ShellAdapter.intent.isAvailable(). A user's preferred handlers can be published as kind 31129 ("Napplet Handler Preferences"), which an applet such as an intent-preferences manager can also write. kehto's ACL (0.20.3, also on main) maps intent.deliver to no recipient capability, so the runtime's recipient-checked sendToEligibleNapplet refuses every delivery; kehto's own reference host, Paja, posts the delivery on the target's bound port with originRegistry.postToNapplet.

Decision.

  • The launcher is a store. kernl.catalog.load() (loadCatalog) reads root and named manifests from the apps and super tiers within an 8 s budget, keeps those whose signature verifies (toCatalogEntry), and lists the newest per catalogue id. searchCatalog matches every search word against title, topics, roles, description and publisher, forgiving a typo only within one word; popularTopics gives the store's topic bubbles. By default the store lists only applets whose required domains this shell offers, and says how many it hides. An entry's bytes are verified only when it is opened. Pasting an address into the search always works, and ?open= still opens one.
  • There are no samples. The preview's store lists the preview's own applets, shaped as store entries; a third, Profiles, takes napplet:profile/open, so the preview shows the whole intent flow offline.
  • Planning installs. kernl.plan installs the verified applet (kernl:installed:v1), and kernl.install installs one without opening it, refusing a summary the shell did not verify (invalid:unresolved). The installed set is the catalogue NAP-INTENT resolves against, so a handler's contracts always come from a verified manifest. AppletSummary.address carries the manifest's shareable address so the shell can open a handler again.
  • kernl offers intent, routed by kehto's resolver. createIntentRouter wraps createCatalogIntentResolver with the user's local default per archetype, their published preferences (one compatible preferred handler is used without asking; several are offered first in the chooser), the host's chooser, and recommendations matched only to installed handlers. When nothing installed can take a request, the user is asked once, with the store narrowed to applets that accept the convention; a handler installed that way is used for that request through a one-time explicit authorisation, so an applet never addresses a handler the user did not pick. Without a chooser an undecided request is refused as invoke rejected.
  • Delivery goes over the target's port, behind the receiving grant. createIntentDelivery reuses a running handler (focused unless asked not to) or asks the host to open it, then retries until the target's live session holds intent:read and posts intent.deliver with originRegistry.postToNapplet, as Paja does. The sender is the verified catalogue id of the asking window, from kernl's own handles. A failure after acceptance is told to the user in plain words.
  • Receiving is a grant. An applet that takes requests is offered the "opening other applets" switch, on at its first run, and an applet that requires it to ask others gets it on too.
  • Handler preferences are the user's. kind 31129 events are read for the signed-in account from its outbox and super relays, verified and kept per archetype; public entries are read, private ones are carried over untouched. A local default always wins. Publishing a choice is off until the user turns on "Keep these choices with your account", because the archetype is public. An applet that writes kind 31129 meets the destructive consent prompt, which names the archetype and the applets added and removed.

Consequences. A store lists other people's titles and descriptions as they wrote them. Its icons load from the manifest's first server, a display accelerator and never identity. Delivery depends on originRegistry.postToNapplet until kehto maps intent.deliver to intent:read; the gap is to be raised upstream. The preferences' private entries are not decrypted, and kehto's getDefaultHandler takes only the archetype, so a convention-specific choice can only rank the chooser.

Reversal cost. Low. The store and router are additive; the delivery send is one function in kernl-intents.ts.

D-027 · Messages are an applet; the user orders the providers, and the shell asks before a reply leaves another way ​

Context. The shell drew conversations itself, in a panel that read the messaging composite directly, while SOUL opinion 5 says every provider is exposed through the same Direct Messenger interface that applets use. The panel also asked the user to pick a provider for every new conversation and kept a single "default" beside an unordered list. NAP-DM (napplet/naps PR #74) puts the choice of transport in the runtime and says a runtime with several implementations MUST NOT weaken privacy silently, without policy or consent. Two things followed from keeping conversations per provider: someone who writes through one provider while the user's first choice is another gets an answer they may never see, and nothing told the user. An applet that speaks NAP-DM cannot be opened in the live shell without a published, signed manifest, and the shell's own messages must not depend on a catalogue relay answering.

Decision.

  • The shell draws no conversation. MessagesPanel is gone. The Messages button opens an applet: the user's default for the dm role when one is installed, otherwise the one kernl ships.
  • kernl ships a Messages applet as a built-in. applets/messages is scaffolded and built with the napplet toolchain into one file. A host lists such applets in KernlOptions.builtins; kernl:<id> decodes to a built-in pointer, and resolve loads the bytes through the host and refuses them unless they hash to the SHA-256 the host pinned (invalid:artifact-hash). No manifest is fetched and no signature is checked, because the host that pins the hash is the page the user already trusts with everything else: a published manifest could add nothing a compromised shell could not forge. The hash is written by the applet's own build beside the artifact. Everything after resolve is the ordinary path: the same sealed srcdoc frame, CSP, locks, guard, grants and intents.
  • A built-in starts with the grants its host names, once. The first time the user meets the Messages applet, dm, identity and notify are switched on, where the old panel needed no grant at all. The record is then the user's: a withdrawn grant is never switched back on.
  • Providers are kept in the user's order, and the first one that is on is the default. MessagingRegistry.setOrder replaces the per-conversation picker; setDefault survives as "move to the top". A message to someone new goes to the first enabled provider and falls to the next when that provider answers unavailable:recipient-not-ready. The order is the user's stated policy, which is what NAP-DM asks for before a weaker transport is used; the shell still ranks nothing (D-009).
  • A reply is checked against the correspondent's last provider. Before a one-to-one message leaves, the composite finds the enabled provider that person last wrote through. When it differs from the planned one, the reply rule decides: ask (the default) puts the question to the host through KernlHost.chooseReply, theirs follows them, mine keeps the order. The answer can be kept for that person or made the rule, and Settings can forget kept answers. Dismissing the question sends nothing (denied:cancelled). The shell owns this because only the shell knows which provider a conversation belongs to; an applet sees opaque ids.
  • The applet shows one thread per person and lets the shell route. It merges direct conversations with the same participants and answers the person, not a conversation id. The composite's ids stay provider-namespaced, so an applet that prefers one thread per provider still works, and the check applies to its sends too.
  • A wanted provider waits for an account. A saved provider that is unavailable when it registers used to be dropped from the saved list; it now waits and starts when it can run. Someone who never chose gets private and legacy switched on. ratchet and group publish something about the account when they start (D-016, D-018), so they stay off until the user switches them on.
  • The preview runs the same bytes. It has no runtime, so it binds the applet's port itself and routes each envelope to kehto's own dm, identity, theme and notify services over its in-memory state, offering the applet only those domains. Its guard looks at every envelope first (D-011).

Consequences. Messages work without the network and are as replaceable as any applet. The applet cannot say which provider carried a message; the question and Settings are where that is visible, and a shell-side indication on the conversation is roadmap work. The user is asked a question the old panel never asked, at most once per person if they keep the answer. Messages are read only after the user opens them: the count on the Messages button starts with the first open, so a signer is not asked to open mail nobody looked for. The strict guard drops what an applet sends past eight requests a second, so the applet paces every call itself. A request another applet hands over as napplet:dm/open opens Messages but not the conversation yet, because the applet's SDK version has no delivery hook. Applets are told the shell's colours when they open and are not told when the theme is switched. The built-in path is a second way bytes become an applet; it is confined to resolveBuiltin and to ids the host listed.

Reversal cost. Medium for the panel (it would be rewritten); low for each rule. The order and the reply rule sit behind createMessagingRegistry and CompositeOptions.chooseReply; a host that passes no chooseReply gets the order alone. Built-ins are one option and one pointer kind.

D-028 · A publish succeeds when any server accepts it; the person sees how many did ​

Context. Everything the shell publishes goes to several relays at once, and applesauce-relay 7 answers RelayPool.publish with one PublishResponse per relay. Each caller read those answers its own way: some threw away the list, some treated any refusal as a failure. A relay that refuses an event is ordinary on a public network (it wants a login, a payment, a different kind, less traffic), so a publish where two of three relays accept is the network working, and reporting it as an error teaches the person to distrust a save that worked. Reporting it as a plain success hides that one copy is missing.

Decision.

  • summarizePublish(responses) folds the answers into a PublishOutcome: accepted URLs, refused URLs each with the relay's own reason (or refused when it gave none), and ok, true when at least one relay accepted. A relay that answers twice counts once, and an acceptance wins, because the event is on that relay either way.
  • describeOutcome(outcome) is the sentence for the person: "Saved to 2 of 3 servers", "Saved to all 3 servers", "No server accepted it", "There was no server to save it to". It never repeats a relay's reason, which is written for developers and often names the protocol (D-006).
  • A publish that throws before any relay answers counts every relay as refusing, with the error's message as the reason, so a caller still gets an outcome instead of an exception.
  • New code that publishes for the person returns a PublishOutcome; AccountMailboxes.save is the first.

Consequences. A save that reached one relay of five is a success, and the person is told it reached one. The refusals stay available, with reasons, for a Settings view or a log. Existing publishers (preferences, messaging, the outbox service) keep their current handling until they are moved over.

Reversal cost. Low. Two pure functions; ok can be redefined as a quorum in one place.

D-029 · The account's inbox and outbox are its published list; a directory of monitored servers suggests and completes, never decides ​

Context. The outbox tier starts empty because kernl does not guess where the user publishes (DEFAULT_RELAY_TIERS), and the account's own NIP-65 list (kind 10002), which every other client reads, was only ever read for other people. An account with no list had no path to one. NIP-66 monitors publish what they measure about thousands of relays (kind 30166), which is what a person needs to choose servers, but NIP-66's risk section is explicit: clients must not require these events, a monitor may be wrong or malicious, and no single monitor should be trusted. Reading the seed data of a public monitor client showed about ten monitors, about 1.4 % of reports without a network tag, requirement tags on almost every report, and type (T) and kind (k) tags on almost none.

Decision.

  • The published list is the truth. createAccountMailboxes streams kind 10002 for the active account from the lookup servers and keeps the subscription open, so a newer list from another client replaces it; the event store keeps the newest. The state is loading until a list arrives or every lookup server has finished (EOSE, CLOSED or a failure) without one; missing comes only then, or after a 15 s cap for a server that never answers. Nothing shorter cuts a lookup off.
  • Saving follows NIP-65. The new list is built from the person's choices: one r tag per server, unmarked when it is both inbox and outbox, read or write otherwise, and every other tag and the content of the previous list kept. created_at is always later than the previous list's. It is published to the new outboxes, the old outboxes, the new inboxes and the lookup servers, since NIP-65 asks for the list to spread to indexers. A list no server accepted is not added to the store, so the shell never shows a list no server holds. A session that cannot sign is refused with denied:read-only; an entry that is not a ws:/wss: URL with invalid:relay-url.
  • The directory only ranks. createRelayDirectory streams kind 30166 from three monitor relays (DEFAULT_MONITOR_RELAYS: relay.nostr.watch, relaypag.es and monitorlizard.nostr1.com, which nostr.watch's client reads; history.nostr.watch was dropped because its certificate does not match its name, so every browser refuses it) for the last two days and the clearnet network, which is all a browser can dial; a report naming another network is dropped, one naming none is kept if a relay sends it. Each monitor's newest report per relay counts; a listing has the number of distinct monitors, the newest sighting, mean round-trip times, the union of NIPs and types, and the requirement flags of the newest report. search completes what the person types; suggest leaves out relays that require a login or payment (and, for inbox and outbox, restricted writes), prefers indexers for lookup (NIP-65 support, an Indexer type, or accepted kinds 0 or 10002), and ranks by monitors, then the hour last seen, then connection time.
  • Zero reports is a supported state. With too few listings, suggest tops up from a fallback: relay.damus.io, nos.lol and relay.primal.net for inbox and outbox, matching the super tier, and purplepag.es, user.kindpag.es and indexer.coracle.social for lookup. A fallback relay the directory knows to require payment is skipped. A missing list's suggestions come from the directory when one is given, else from the fallback, three of each, the middle of NIP-65's 2–4.
  • A directory flag never stops a connection or removes a server the person chose. Counting distinct monitors rewards agreement but not trust: anyone can sign reports under many keys, so the count orders suggestions and nothing more.

Consequences. A new account gets a working inbox and outbox in one save, and an existing one sees and edits the list every other client uses. The directory costs one long-lived subscription and up to a few tens of thousands of small events while it runs; it is started by the host, not by default. A single hostile monitor can mark a relay as paid and drop it from suggestions, which the person can still type. A web-of-trust filter over monitors is not attempted. Read relays of the account's old inboxes are not sent the new list.

Reversal cost. Low. createKernl composes both (kernl.mailboxes, kernl.directory) and lets a found list's outboxes become the outbox tier; the ranking and the fallback lists are constants and one comparator.

D-030 · Events are cached through one adapter contract; the web default is sqlite over OPFS, then IndexedDB, then memory; a corrupt database is wiped or rotated, never trusted ​

Context. The shell's EventStore lives in memory, so every visit starts by asking relays again for profiles, relay lists, follows and messages it saw a minute ago. applesauce leaves the cache to the application: persistEventsToCache writes new events in batches, markFromCache and isFromCache keep cached events from being written back, the loaders take a cacheRequest, and IEventDatabase / IAsyncEventDatabase describe a database, with applesauce-sqlite providing better-sqlite3, libsql and turso ones. In the browser the fastest durable store is sqlite-wasm over the origin-private file system. Its plain OPFS VFS needs cross-origin isolation, which the shell's applet frames and hosting do not have; the SAH pool VFS needs none but runs only in a dedicated worker and holds exclusive handles, so a second tab cannot open the same database. A database file can be damaged (a crash mid-write, a storage bug, a browser that evicted part of the origin), and a damaged sqlite file either refuses to open (SQLITE_NOTADB), fails at some later read (SQLITE_CORRUPT), or opens and lists problems in PRAGMA quick_check. Elsewhere kernl will want the same cache over postgres, MySQL, LMDB or nostrdb.

Decision.

  • @kernlize/core/cache defines EventCache (add, query, removeByFilters, clear, close) and EventCacheAdapter (name, open). An adapter that cannot work in its context rejects open with unavailable:<reason>. openEventCache(adapters) opens the first that succeeds, reports the others through onFallback, and falls back to memory; it never rejects. The browser's order is defaultWebCacheAdapters(): sqlite-opfs, indexeddb, memory.
  • One rules layer, createEventCache(name, records), sits over every backend, so they cannot disagree: the newest replaceable (0, 3, 10000–19999) and addressable (30000–39999, per d) version wins, with the lower id on a tie; a kind 5 deletion removes its author's targets, by id and by address up to its own time, and keeps them out when they arrive again; ephemeral events are not stored; writes run one after another. A backend only stores rows and answers one filter at a time. Filters support ids, authors, kinds, since, until, limit and single-letter tags; search answers nothing.
  • fromEventDatabase(name, database, close?) turns any applesauce event database into a cache, so a server or native host brings applesauce-sqlite's better-sqlite3 or libsql database, or a future postgres one, without kernl knowing about it. The memory backend is applesauce's own EventMemory through it.
  • The IndexedDB backend is kernl's own, with no library: one object store keyed by id, indexes on kind, author and time, and a multi-entry index of name:value tags.
  • The sqlite backend runs sqlite-wasm on the SAH pool VFS in a dedicated worker, loaded only there, with applesauce-sqlite's schema so the file stays readable by applesauce's own sqlite databases. The SQL sits behind a small driver (exec, query, close, plus wipe on the files), so it is tested in node against real sqlite-wasm in memory.
  • A damaged database is never read. On open the cache runs PRAGMA quick_check; damage then, or SQLITE_CORRUPT / SQLITE_NOTADB met later, closes the file, removes it through the pool and opens it fresh. When the removal fails, or the fresh file is damaged too, the cache moves to the next filename (/kernl-events-1.sqlite3, …) and remembers it at kernl:event-cache:v1. When that is damaged as well it gives up: on open it rejects unavailable:corrupt so the next adapter is used; in use, every call is refused with unavailable:corrupt. Concurrent calls that meet damage share one recovery, and each is retried once. onRecover reports wiped, rotated or disabled.
  • A Web Lock guards the database. A second tab does not wait for it: open rejects unavailable:locked and that tab uses IndexedDB.
  • attachEventCache(store, cache) writes through persistEventsToCache and gives loaders a cacheRequest that marks what it returns as from the cache and resolves nothing when the cache fails. A cache is an accelerator, never identity: events read from it go through the store like any other.

Consequences. @kernlize/core gains @sqlite.org/sqlite-wasm (pinned, since its versions are pre-release-shaped) and applesauce-sqlite, whose helpers import only applesauce-core. The worker is its own build entry, emitted beside the chunk that starts it by URL, and the build fails if it is not. Recovery discards the damaged cache's contents, which relays can serve again; nothing in the cache is the only copy of anything. Two tabs cache in two places. IndexedDB queries narrow by one index and filter the rest in memory, so a broad filter over a large IndexedDB cache reads more than sqlite would. Wiring the cache into createKernl is a separate change.

Reversal cost. Low. The cache is additive behind one contract; dropping an adapter from defaultWebCacheAdapters changes the order, and not attaching a cache returns the shell to memory only.

D-031 · People are shown by the name they published; the store and profiles stream; servers are named by the role they play ​

Context. The shell showed people as abbreviated keys everywhere, because nothing in it loaded profiles (kind 0) for the UI, and the one loader that did asked only the two discovery relays. The store waited a fixed eight seconds, then threw away what had arrived if any relay group failed. Settings called the account's write relays "Your connections" and every list "Server address", which read as the people you follow and gave no way to tell a message server from a file server. A relay-list consent showed (write) markers.

Decision.

  • Always look for kind 0. createProfiles asks the lookup relays (discovery, then super) for the profile and the relay list at once; when the list names write relays the profile was not found on, it asks there (NIP-65). A card never gives up on a timer, and a late profile replaces the short key. The session label follows the account's published name. Every person in the shell is drawn through one component that does this, and the dm domain names conversation participants the same way before an applet sees them, so applets get names without asking.
  • Stream, and bound only at the extremes. The store emits its list as each relay answers, says "no applets match, more may still arrive" after CATALOG_SETTLE_MS (3 s) with nothing, and stops listening at CATALOG_TIMEOUT_MS (30 s). Cached manifests are listed first. A manifest its publisher deleted (kind 5) is left out. Every published applet is listed by default, as the public stores list them; one that needs a capability this shell does not offer says which, and "Only applets that work here" hides those when the person asks.
  • Name servers by role. Settings has "Inbox and outbox", the account's published list (D-029), with "Inbox: where people send things to you" and "Outbox: where your posts are kept, and where others look for them", and says that pictures and applet files live on separate file servers. The shell's own lists are "People lookup", "Applet catalogue" and "General". The consent for a relay list says "Change your inbox and outbox" and lists what each side gains and loses. The word for the protocol's server still never appears (D-006).

Consequences. Names appear wherever a key did, from cache at once and from the network as it answers. The store fills from the first relay. The outbox tier is no longer edited by hand; it follows the published list.

Reversal cost. Low. Each is one module behind an interface the preview also implements.

D-032 · A link is right only if the production host serves it; the artifact carries clean URLs itself, and a gate checks every link ​

Context. D-019 moved the site from GitHub Pages to a bunny.net static site. GitHub Pages answers /docs/decisions with decisions.html; bunny.net serves a file, or a directory's index.html with or without the trailing slash, and nothing else; the bunny sites CLI offers no setting for it. VitePress builds with cleanUrls, so every docs page was written as <page>.html and linked as /docs/<page>. From the move on, every docs page except the section indexes answered 404 when opened directly, which included two of the six links in the landing footer and every sidebar link on a fresh load. Nothing noticed: scripts/serve-pages.mjs fell back to <path>.html, so the preview and every browser test that ran against it were more forgiving than production, VitePress's own dead-link check knows nothing about the host, and no test followed a link on the deployed site.

Decision.

  • One statement of the host's rules. scripts/pages-host.mjs exports servedFile(root, pathname): the file bunny.net serves for a path, or null for a 404. The preview server answers through it and no longer guesses an extension or redirects a directory to its trailing slash, so the preview cannot serve what production would not.
  • The artifact carries its own clean URLs. build-pages.mjs writes each VitePress page a second time as <page>/index.html, with its Markdown twin as <page>/index.md, and fails if two pages would claim one address. /docs/<page> and /docs/<page>.html both answer, on any static host. cleanUrls stays on, so the addresses already written in copy, in llms.txt and in this log are the ones that work. TypeDoc output is left alone; its links carry .html.
  • A gate reads every link. pnpm check:links (scripts/check-links.mjs) reads every HTML page, Markdown twin, llms.txt and sitemap in .pages/, resolves each internal link through servedFile, and checks that a #fragment names an id on the target page. It runs in CI on every pull request, in pnpm check, and in the publish workflow before the deploy.
  • The deployed site is asked too. After a deploy, the publish workflow runs pnpm check:links --live, which requests every internal path from kernl.run and fails on anything but 200. servedFile is a model of the host; this is the check that the model still matches it.
  • An outbound link fails only when its target is gone. CI passes --external. A 404, a 410 or a domain that no longer resolves fails the run. A rate limit, a bot wall, a server error or a timeout is printed as unverified and does not, because none of them says the link is wrong and a gate that fails for a third party's bad afternoon gets switched off.

Consequences. Each docs page exists twice in the artifact (37 pages today), with the same canonical address. The copy at /docs/<page>/, with the trailing slash, loads, but relative links inside it resolve one level too deep; nothing links to that form, and the gate scans the original so it does not report the copy. A pull request can be blocked by an outbound page that was removed since the last run; the fix is to update or drop the link. The live check runs after the deploy, so it reports a bad deploy rather than preventing one; the response is the rollback in D-019. If bunny.net later offers extension fallback or an Edge Script provides it, the copies become unnecessary but stay harmless.

Reversal cost. Low. Removing the copies is one call in build-pages.mjs; the gate and servedFile are independent of how clean URLs are provided, and a host with different rules changes only pages-host.mjs.

D-033 · Applets upload through the shell to the account's own file servers, with Blossom's own client, and the person is asked before each file leaves ​

Context. Sixteen published applets require upload (NAP-UPLOAD), and the shell did not offer it. kehto ships the upload service and a reference HTTP uploader that sends a file to one server. Blossom, the file-server protocol most clients use, has a client SDK by its author that uploads to one server, mirrors to the others, checks first whether a server already holds the file, and handles the protocol's rejection and payment answers. BUD-03 gives every account a published list of its file servers (kind 10063).

Decision.

  • The wire is kehto's, the transfer is Blossom's. createUploadService answers applets. Behind it, createBlossomUploader uses blossom-client-sdk's multiServerUpload: the file goes to the first server and is mirrored to the rest. An upload is a success when any server stores it, and the result lists every copy (D-028). When none does it fails with unavailable:not-accepted:0/<n>.
  • The account decides where files go. kernl.files follows the account's kind 10063 list and waits for the lookup to finish before falling back to DEFAULT_FILE_SERVERS (blossom.primal.net, nostr.download, blossom.band, which answered an upload preflight when chosen). An applet may not name a server. Settings shows the list and publishes changes to the account's outboxes and the lookup servers.
  • Every file is asked about. Before anything is signed or sent, the person sees "Upload a file", marked public, with the file's name and type, its size, the servers it goes to, and "Anyone who has its address". The prompt appears once per file. One authorization (kind 24242) is then signed and covers every server. A read-only session is refused with denied:read-only, and an applet the shell cannot name is refused without asking.
  • ConsentSummary.severity gains public for writes that put something where anyone can fetch it.

Consequences. Applets that need uploads now run, and their files land where the account's other clients look for them. A file the person allowed is public for good: Blossom has no private uploads, so the prompt says so.

Reversal cost. Low. The uploader is one module behind kehto's Uploader interface, so a host can pass its own.

D-034 · Merging the Version Packages PR is the release; the registries say what is missing ​

Context. D-021 made release.yml the only publisher, started by a v* tag or by hand. Nothing had been published: the Version Packages PR merged twice and the tag never followed, because the step after the merge was a person remembering to run a workflow. Changesets' own action can publish from the versioning workflow, but it decides by the absence of pending changesets, which is true of most pushes to main, and it would put the publish credentials and the full validation into the workflow that opens pull requests. npm's trusted publishing cannot carry a package's first version, which no package had yet, and setup-node writes an authentication line into .npmrc whether or not the secret behind it exists, after which npm does not try OIDC.

Decision.

  • A release is whatever main carries that a registry does not have. release.yml runs on every push to main. Its first job, scripts/check-unpublished.mjs, asks npm and JSR for each public package's versions; the rest of the workflow runs when a registry lacks one. Merging the Version Packages PR is what makes a version missing, so merging it is the release. A v* tag or a manual run publishes without asking. A registry that cannot be asked fails the job; an outage never reads as "nothing to publish".
  • The npm job chooses its credential. It writes .npmrc itself: with the NPM_TOKEN secret, a token line, for a package's first version; without it, no token, and npm exchanges the job's OIDC token with the trusted publisher. The log says which it used.
  • A last job asks again. After both publishers report success, the registries are asked once more; a version still missing fails the run. Both publishers skip versions that exist, so the next push to main finishes whatever a failed run left.

Consequences. Releasing needs no action beyond merging. Every push to main costs two registry lookups, and a push that follows a merge of the version PR costs the full validation again (CI ran it on the PR, but against the branch, not the merge). The first publish of each npm package needs the token secret set once and deleted once the trusted publishers exist; the how-to records the order. check-unpublished is also the local answer to "what would a release publish now".

Reversal cost. Low. The push trigger and the pending job are the change; the tag and manual paths remain.

D-035 · Applets take social actions through the shell; changing who the person follows is shown to them first ​

Context. Twelve published applets require common (NAP-COMMON): read a profile or the person's follows, follow and unfollow, react, report. kehto ships the service. The host supplies the hooks.

Decision.

  • Reads are public data. getProfile accepts a key or a shared profile address and answers from the profile service, which follows the author's own servers (D-031). follows reads the account's current follow list.
  • Writes are the person's. Each write is signed by the account's signer through the shell's signing gate and published to the tier for its kind; it succeeds when any relay accepts it (D-028).
    • follow and unfollow replace the follow list (kind 3). The current list is loaded first, and every other entry, tag and the content are kept. Because the change replaces part of the public record, the person sees "Change who you follow" with who is added and removed before anything is signed.
    • react (kind 7) names the event and, when the shell has seen it, its author and kind, plus a custom emoji when given.
    • report (kind 1984, NIP-56) names its target and reason.
    • Reactions and reports are posts and are not asked about, like any other post.
  • A read-only or signed-out session cannot write (denied:read-only). A key that is not one is invalid:pubkey.

Consequences. Social applets run in the shell without holding a key or a relay connection, and a follow-list change can never be made behind the person's back.

Reversal cost. Low. createCommonActions is one module of hooks; a host can pass its own.

MIT licensed. Protocol authority: NIP-5D (nostr-protocol/nips PR #2303) and the NAPs (napplet/naps).