Repository navigation
feat: app installs — manifests, the _install record, and POST /installs - #400
Merged
Merged
Conversation
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 detectedLatest commit: 26e2eac The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
…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
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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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@1system type.appId, andappIdis an immutable binding._appcards it was installed for and the_grantrecords it produced, usingrelationshipassociations.Stack.planInstall()→installApp()→uninstallApp().typeChanges: every type it would define or redefine, commons types included;linkedKeys: the keys already linked to the install.defineType()would refuse, a type listed twice, and a non-additive schema change. That means a manifest's types are written all or none.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 getsreadon the install, and a key no longer linked loses it.nameorversionis not empty, so the owner still sees it._appcards stay.Each family has one owner: the app whose
appIdis 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:update-anythrough a grant made out to it directly;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.{ manifest }. The key being installed always comes from the session, never the body.202 pending(the request is queued and nothing is written to the stack) or200 installedwith the_installrecord.installs: { requests: true }.APIAdapter.requestInstall(),parseInstallBody()andisPlanEmpty().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.mddescribes the remainingappIdrisk and links to where it's deferred.Spec
docs/spec/apps.md, covering the_installrecord, who owns a family, plan-then-apply, migration by an installed app, uninstalling, and the wire request.docs/spec/wire-format.md § Installs, plus theinstallsfield in Discovery.access-control.md:_installcan't be granted and is fenced; thecommitMigration()exception.data-model.md,identity.md § App,versioning.md,wire-format.md(Migration commit and Types), thedocs/spec.mdindex, andREADME.md.installRequestFixturesand 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.tscovers:typeChanges,linkedKeys, and the frozen manifest;nameorversion;sweep: 'listed'.wire-body.test.tscoversparseInstallBody().adapter-apiruns against the new fixtures, including the local refusal when discovery doesn't advertise installs.Notes for reviewers
appIdclaim is a known residual risk. Any key can present a manifest under anyappId. The plan shows theappId,newKeyandlinkedKeys, so the owner's review is the check. Seeapps.md § Who owns a familyand Design: proving who publishes an app (signed manifests, publisher pinning, certified keys) #404.planInstall()/installApp(). Only the app's request is pinned.WIRE_PROTOCOL_VERSIONstays1.0. The changes only add things.🤖 Generated with Claude Code
https://claude.ai/code/session_01AdV1VbrMeuGtMoFiE31tqg