Problem
A dwapp's dweb.version_id is a provenance claim, not an integrity binding. Three concrete consequences, all verified in source:
- Writes don't check provenance.
app_write_file / app_update / edit_file route to appClient.writeFile / update (background/app-client.js), which resolve an appId and write straight into OPFS peerd-apps/<appId>/ with no check of record.dweb, record.source, or the manifest hash. The agent (or the user via the app tab's Edit toggle) can silently rewrite code installed from a peer.
- Nothing re-verifies at run time.
engine-tabs/app-tab/app-tab.js composes whatever is in OPFS; there is no comparison against dweb.version_id.
- Grants survive local mutation. The bridge grant key is derived from the stored
dweb.hash (peerd-distributed/apps/bridge.js, appKey), which editing doesn't update — so a locally modified dwapp keeps the room grants the user approved for the original publisher's bytes.
Additionally, the update path (background/routes/dweb.js app-update, deliberately replace-not-merge) will silently clobber local modifications with no conflict detection.
Design intent: fork state, not immutability
Local mutability is the product thesis — "your agent modifies your software" — so the fix is not Sandstorm/Cloudflare-OS-style immutable packages. It's making the fork honest:
- Fork transition. On first write to an app whose record carries
dweb with a non-null hash: mark the record forked (e.g. dweb.forked: true), preserving lineage (base publisher, dwapp_id, version_id) for display as "modified from <publisher>". Registry change lands in peerd-engine/app-registry.js (note: dweb is currently excluded from applyPatch by design — the fork flag needs a sanctioned mutation path, not a loosening of that rule).
- Grant rotation on fork. The bridge grant key becomes fork-scoped (e.g. recompute from current content, or
fork:<appId>), so the next join re-consents under the new bytes. This closes the "approved bytes ≠ running bytes" gap without blocking the edit itself.
- UI surface. "Locally modified" badge in the app tab; shown in the library/catalog if relevant.
- Update conflict handling.
dweb/base/update-app on a forked app must not silently overwrite — require an explicit confirm ("discard local changes and update to the publisher's vN?") or decline. Old-version seed data is already retained (offscreen/dweb-base.js keeps prior bundles as "the substrate for a future revert/changelog") — this is the first consumer.
- Reshare already does the right thing — republishing a modified app goes out under your publisher DID and slug (
dwapp_id = H(publisher‖slug), peerd-distributed/apps/meta.js), so attribution can't be spoofed. Optional follow-up: carry base-lineage in the reshared card.
- Optional follow-up: verify-on-launch. For non-forked dweb apps, recompute the bundle hash at app-tab load and compare with
version_id — catches tampering via any code path that didn't take the sanctioned fork transition. Needs a perf look (bundles are ≤50MB; hash lazily/cache by OPFS mtime).
Tests
- Bun: registry fork-transition semantics; grant-key derivation before/after fork.
- In-browser/e2e: editing a dweb-installed app forces re-consent on the next room join; update-on-forked-app prompts instead of clobbering.
Prior art
Cloudflare OS keeps app code as an immutable content-addressed snapshot (Blueprints) and puts all mutability in per-instance data; Sandstorm likewise (spk packages vs. grain storage). peerd deliberately inverts this — which is exactly why the fork boundary has to be explicit rather than implicit.
Related: #235 (dwapp actors epic — actors driving dwapps raises the stakes on knowing which bytes are running).
Problem
A dwapp's
dweb.version_idis a provenance claim, not an integrity binding. Three concrete consequences, all verified in source:app_write_file/app_update/edit_fileroute toappClient.writeFile/update(background/app-client.js), which resolve an appId and write straight into OPFSpeerd-apps/<appId>/with no check ofrecord.dweb,record.source, or the manifest hash. The agent (or the user via the app tab's Edit toggle) can silently rewrite code installed from a peer.engine-tabs/app-tab/app-tab.jscomposes whatever is in OPFS; there is no comparison againstdweb.version_id.dweb.hash(peerd-distributed/apps/bridge.js,appKey), which editing doesn't update — so a locally modified dwapp keeps the room grants the user approved for the original publisher's bytes.Additionally, the update path (
background/routes/dweb.jsapp-update, deliberately replace-not-merge) will silently clobber local modifications with no conflict detection.Design intent: fork state, not immutability
Local mutability is the product thesis — "your agent modifies your software" — so the fix is not Sandstorm/Cloudflare-OS-style immutable packages. It's making the fork honest:
dwebwith a non-nullhash: mark the record forked (e.g.dweb.forked: true), preserving lineage (basepublisher,dwapp_id,version_id) for display as "modified from <publisher>". Registry change lands inpeerd-engine/app-registry.js(note:dwebis currently excluded fromapplyPatchby design — the fork flag needs a sanctioned mutation path, not a loosening of that rule).fork:<appId>), so the nextjoinre-consents under the new bytes. This closes the "approved bytes ≠ running bytes" gap without blocking the edit itself.dweb/base/update-appon a forked app must not silently overwrite — require an explicit confirm ("discard local changes and update to the publisher's vN?") or decline. Old-version seed data is already retained (offscreen/dweb-base.jskeeps prior bundles as "the substrate for a future revert/changelog") — this is the first consumer.dwapp_id = H(publisher‖slug),peerd-distributed/apps/meta.js), so attribution can't be spoofed. Optional follow-up: carry base-lineage in the reshared card.version_id— catches tampering via any code path that didn't take the sanctioned fork transition. Needs a perf look (bundles are ≤50MB; hash lazily/cache by OPFS mtime).Tests
Prior art
Cloudflare OS keeps app code as an immutable content-addressed snapshot (Blueprints) and puts all mutability in per-instance data; Sandstorm likewise (spk packages vs. grain storage). peerd deliberately inverts this — which is exactly why the fork boundary has to be explicit rather than implicit.
Related: #235 (dwapp actors epic — actors driving dwapps raises the stakes on knowing which bytes are running).