Skip to content

feat: app installs — manifests, the _install record, and POST /installs - #400

Merged
cuibonobo merged 12 commits into
mainfrom
claude/busy-meitner-ejuhvg
Oct 5, 2026
Merged

cuibonobo merged 12 commits into
mainfrom
claude/busy-meitner-ejuhvg

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #359. An app that holds its own key can now get its types into a stack in a single step that the owner reviews.

  • _install@1 system type.

    • It stores what the owner approved, not the manifest the app shipped.
    • There is one install per appId, and appId is an immutable binding.
    • It can't be granted, and only the owner, acting alone, can write one.
    • It links to the _app cards it was installed for and the _grant records it produced, using relationship associations.
    • Its version history is the upgrade log.
  • Stack.planInstall() → installApp() → uninstallApp().

    • What the plan reports:
      • new families and new versions;
      • typeChanges: every type it would define or redefine, commons types included;
      • requests added and removed;
      • requests on families the app doesn't own, with each family's owner;
      • linkedKeys: the keys already linked to the install.
    • What the plan refuses up front: any schema defineType() would refuse, a type listed twice, and a non-additive schema change. That means a manifest's types are written all or none.
    • Comparing requests: a request is compared by its family and the set of its actions, so repeating an action can't hide a dropped grant.
    • Applying: installApp() refuses a plan that's out of date, and works from a frozen copy of the manifest. It sets every linked key's grants to exactly what the manifest requests. Each linked key gets read on the install, and a key no longer linked loses it.
    • Empty plans: a plan that changes only name or version is not empty, so the owner still sees it.
    • Uninstalling revokes the install's grants and soft-deletes it. The app's data and its _app cards stay.
  • Each family has one owner: the app whose appId is its namespace. An install may define commons types (org.haverstack/*) but never claims them. An app uses another app's family through a request, never by defining it.

  • An installed app can migrate its own types. ScopedStack.commitMigration() admits the app when all of these hold:

    • both families are in its live install's namespace;
    • the target version is one the owner approved;
    • the app holds update-any through a grant made out to it directly;
    • the request comes from the app's own key, acting as itself.

    The new content goes through the file-reference check a create does. A soft-deleted record is refused until it's undeleted. migrateAll({ sweep: 'listed' }) returns { migrated, skipped }.

  • POST /installs.

    • The body is { manifest }. The key being installed always comes from the session, never the body.
    • The server answers 202 pending (the request is queued and nothing is written to the stack) or 200 installed with the _install record.
    • Discovery advertises the endpoint with installs: { requests: true }.
    • New exports: APIAdapter.requestInstall(), parseInstallBody() and isPlanEmpty().

Out of scope: proving who publishes an app (signed manifests, publisher pinning, certified keys) moves to #404. An earlier revision of this PR prototyped it, and the last commit removes it. It came down mostly to trust on first use, it added a lot of edge cases, and it overlaps the deferred key-rotation design. apps.md describes the remaining appId risk and links to where it's deferred.

Spec

  • New: docs/spec/apps.md, covering the _install record, who owns a family, plan-then-apply, migration by an installed app, uninstalling, and the wire request.
  • New: docs/spec/wire-format.md § Installs, plus the installs field in Discovery.
  • Updated:
    • access-control.md: _install can't be granted and is fenced; the commitMigration() exception.
    • data-model.md, identity.md § App, versioning.md, wire-format.md (Migration commit and Types), the docs/spec.md index, and README.md.
  • Fixtures: installRequestFixtures and a discovery fixture in @haverstack/conformance-fixtures.

Verification

All six pre-push checks pass locally on the current head: format:check, check:refs, lint, test, build, typecheck.

  • packages/core/tests/install.test.ts covers:
    • plan and apply, upgrades, multiple keys, and stale plans;
    • typeChanges, linkedKeys, and the frozen manifest;
    • plans that change only name or version;
    • comparing requests as sets of actions;
    • schema refusals at plan time;
    • namespace and commons rules;
    • field immutability and the write fence;
    • read access following linked keys;
    • uninstall and reinstall;
    • every refusal path for migration, including a soft-deleted record and a file reference the app can't read;
    • sweep: 'listed'.
  • wire-body.test.ts covers parseInstallBody().
  • adapter-api runs against the new fixtures, including the local refusal when discovery doesn't advertise installs.

Notes for reviewers

  • The appId claim is a known residual risk. Any key can present a manifest under any appId. The plan shows the appId, newKey and linkedKeys, so the owner's review is the check. See apps.md § Who owns a family and Design: proving who publishes an app (signed manifests, publisher pinning, certified keys) #404.
  • The owner's approval step isn't specified, on purpose. A server builds that UI on planInstall() / installApp(). Only the app's request is pinned.
  • WIRE_PROTOCOL_VERSION stays 1.0. The changes only add things.
  • Some server behavior isn't exercised here. There's no server in this repo, so the pending queue and the 403 for a delegated session are specified and pinned by fixtures but not run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AdV1VbrMeuGtMoFiE31tqg

claude added 3 commits October 4, 2026 20:23
An app that holds its own key could not get its types into a stack:
defining a type, registering its _app card and granting it types were
separate owner calls with nothing tying them to what the app asked for.

planInstall() reports what applying an app's manifest would change, and
installApp() applies exactly that plan. The approval is stored as an
_install@1 Record, one per appId, that claims the type families it
defines and links by association to the _app cards and _grant Records it
produced. Its version history is the upgrade log; uninstallApp() revokes
the linked grants and soft-deletes it. _install is ungrantable and only
the owner acting alone writes one.

An installed app may commit migrations within the families its install
claims, to versions the owner approved, when it holds update-any on them
by direct grant and acts as its own key. migrateAll() gains
{ sweep: 'listed' } so such an app can sweep what it can enumerate; the
rest migrate lazily through commitMigration().

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE
Claims were first-come: on a stack where an app was never installed, the
first manifest listing its family took it over, migration rights
included, and nothing stopped a manifest claiming a commons family.

A family now belongs to the app whose appId is its namespace. A manifest
may list commons types so they get defined, but defining one claims
nothing and no app may migrate a commons family. A type in another app's
namespace is refused in favour of a request, which the plan reports with
the family's owner. Claim uniqueness follows from appId uniqueness, so
the separate claim check goes away.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE
…erver

Without a pinned endpoint every server would invent its own install
request and every app would special-case every server. The app's half of
an install is now on the wire; the owner's approval stays the server's.

POST /installs takes { manifest } from a key acting as itself. The key
is the session's, never the body's, so no app can ask on behalf of a key
it does not hold. It answers 202 pending while applying the manifest
would change something (the server queues it and writes nothing to the
stack) and 200 with the _install record once the plan is empty.
installApp() gives each linked key read on its own install so the app
can see what was approved. Discovery advertises the endpoint, and
APIAdapter.requestInstall() refuses locally when it is absent.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE
@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 26e2eac

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@haverstack/core Minor
@haverstack/wire-types Minor
@haverstack/conformance-fixtures Minor
@haverstack/adapter-api Minor
@haverstack/adapter-conformance Patch
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/blob-adapter-s3 Patch
@haverstack/commons Patch
@haverstack/record-adapter-do-sqlite Patch
@haverstack/record-adapter-sqlite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

claude added 8 commits October 4, 2026 21:20
…e an app migrating a tombstone

Security review of the install flow found three gaps:

- A key presenting an existing app's appId joins that install as the same
  app: it inherits its grants and migration rights, and its requests
  replace every linked key's. The plan only said `newKey`. It now lists
  `linkedKeys`, and the spec states the residual plainly.
- Commons types in a manifest were defined by installApp() but appeared
  nowhere in the plan, so an app could fix a commons version's shape
  unseen. Name changes and schema widening on approved versions were
  invisible too. The plan now carries `typeChanges` for every type it
  would write.
- An installed app could commit a migration to a soft-deleted record it
  cannot read. That is now refused with StackConflictError until the
  record is undeleted, as apps.md already described.

Both new plan fields are in the stale-plan fingerprint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9
…ed manifest

- installApp() now removes record-level `read` on the install from any
  key of the app that is no longer linked (its `_app` card deleted, say),
  while leaving readers the owner added by hand.
- planInstall() keeps a deep-frozen copy of the manifest, so the plan
  applies what was reviewed even if the caller's object is mutated
  between planning and applying.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9
An appId was the manifest's own claim: any key could present a manifest
under com.example.notes, take its families where the app was not
installed, or join the existing install as the same app where it was.

A manifest now names a publisher DID and travels signed over
manifestPayload(), canonical JSON under a versioned label. planInstall()
refuses one its publisher did not sign. The first install pins the
publisher as a binding on _install, so a manifest under the same appId
signed by anyone else is refused whichever key presents it. did:key
publishers verify from the DID; any other method needs a verifyPublisher
callback, and a did:web publisher whose host reversed is the appId marks
the plan namespaceVerified.

POST /installs, APIAdapter.requestInstall() and the fixtures carry
{ manifest, signature }; the fixture signatures are real so a server can
verify them.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE
…rotation after uninstall

A signed manifest proves who published it but not which key presents it:
manifests ship with their apps, so any key could copy one and join an
install as the same app.

- A publisher may certify a key with certifyKey() over
  keyCertificatePayload({ appId, did }). The app sends it as
  `keyCertificate` on POST /installs; the plan reports `keyCertified`.
  It is optional, since an app on users' devices can only get one from a
  publisher service whose issuing decides what it proves. A certificate
  that is presented and wrong is refused, never read as absent.
- Manifests carry a positive integer `release`, stored on the install.
  planInstall() refuses an older one, so a signed manifest can't be
  replayed over a newer install.
- The publisher pin holds while the install is live. After uninstalling,
  a new publisher may take the install up (`publisherChanged`), which
  unlinks the old publisher's keys. `publisher` is no longer a binding,
  so the install can be patched to the new one.

Fixtures are re-signed under a new fixture publisher key, and gain
certified, misdirected-certificate and older-release cases.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9
- An installed app's commitMigration() applies the file-reference gate
  to the new content, so a migration cannot point a record at an
  attachment the app cannot read and then download it.
- sameRequest() compares action sets, so padding a request with a
  repeated action can no longer hide a dropped grant from the plan.
- Once a key joins with the publisher's certificate, the install sets
  keysCertified and refuses a new key that presents none, so a copied
  manifest can't join an app whose keys are certified. A new publisher
  taking up an uninstalled install starts without it.
- parseInstallBody() refuses derived Type keys in a manifest type rather
  than dropping them, which made genuine signatures fail to verify.
- isPlanEmpty() counts a name, version or release change, so such an
  upgrade reaches the owner and is recorded.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017VerGQ4c2eMbvTM9szXEC7
installApp() defines a manifest's types one at a time before writing
anything else, so a type defineType() refuses left the earlier ones
written with no _install record. A malformed schema on an already
defined type also crashed hashSchema() with a TypeError (a 500 over
POST /installs).

planInstall() now refuses up front what defineType() would: a malformed
schema or field name (StackValidationError), a type listed twice, and a
non-additive change to a defined type (StackSchemaDriftError). A
manifest's types are now written all or none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WUE2XGg8HhX21XHfPCevLD
…installs

Signed manifests, publisher pinning, forward-only releases and certified
keys are a trust layer that the install model doesn't need in order to work,
and with a did:key publisher they still come down to trust on first use,
with the owner's review as the real check. They also overlap the identity
problem identity.md defers under key rotation. They will be designed
there, in a follow-up issue.

The install model, POST /installs, migration by an installed app, and
the review fixes that don't depend on signing all stay: the
file-reference gate on an app's migration, comparing requests as sets of
actions, a name- or version-only change counting as a non-empty plan, and
refusing invalid manifest schemas at plan time. A manifest type's
derived keys are once again accepted and ignored, as on POST /types.
That refusal existed only to keep signatures valid.

apps.md states the remaining appId risk and where it is deferred.

Refs #359

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AdV1VbrMeuGtMoFiE31tqg
…tall plan

planInstall() read linked cards through get(), which hides soft-deleted
ones, so a key whose card the owner deleted planned as newKey: false and
was missing from linkedKeys. installApp() then undeleted the card and
restored its grants without the plan showing it, and resending the same
manifest produced an empty plan that a server applies without asking.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015LB1V25YTpWsyq2sdsg59r
@cuibonobo
cuibonobo merged commit 854c192 into main Oct 5, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the claude/busy-meitner-ejuhvg branch October 5, 2026 09:23
@cuibonobo cuibonobo mentioned this pull request Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design: app manifests, so an app the owner doesn't run can get its types into a stack

2 participants