Repository navigation
feat(core): every installApp() names the plan it approves - #478
Merged
Merged
Conversation
installApp() refuses a plan the stack has moved on from by comparing
fingerprints, but only against the plan object it is handed. A server
shows the owner a plan in one request and applies it in another, so it
must plan again at approval and had no way to say which plan the owner
saw: the check could not fail, or the server had to re-derive it.
An InstallPlan now carries its fingerprint, a SHA-256 over the manifest
(keys sorted) and every stack-dependent part of the plan, and
installApp(plan, { fingerprint }) refuses with StackConflictError unless
planning again still produces that plan. One rule, one owner, one error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
🦋 Changeset detectedLatest commit: 993bd14 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 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 |
…nonical forms
Review of the first cut: installApp(plan, { fingerprint }) compared
against `opts.fingerprint ?? plan.fingerprint`, so the plan's own fields
were ignored when the option was given and the check had two inputs, one
overriding the other.
- installApp(manifest, { did, ifFingerprint }): every apply names the
plan it approves, in-process or carried between requests — one rule,
no override, no plan field to forge. Named like ifVersion.
- The fingerprint uses the forms core already compares in: a type by id,
name, migratesFrom and schemaHash, a request by family and set of
actions, every list sorted. Reordering types, requests, actions or enum
values no longer changes it.
- sha256Hex() is the one hashing helper, shared by hashSchema(),
planFingerprint() and MemoryAdapter's FileIds.
- Spec: one "Nothing is applied that was not approved" rule; why a stale
approval is StackConflictError rather than StackVersionConflictError;
the plan-then-apply window stated as a residual.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
Review: duplicate requests and repeated actions were merged for the fingerprint but stored as given, so two plans could share a fingerprint while writing different _install.requests. Rather than refuse repeats — core already reads requests as sets of actions, and pins that a repeated action is accepted — planInstall() puts requests in canonicalRequests() form once, after validation, so the install stores what the plan compared. The fingerprint then reads every list one way, sortedSet(). Spec: the invariant is "the same thing as core compares it", with a first-defined schema's bytes named as the one thing outside it; and versioning.md states the if* rule — a precondition whose refusal names the remedy — that ifVersion and ifFingerprint both follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
…at entry Review: the canonicalization mixed two approaches — requests normalized and stored sorted, types stored as given and sorted only when hashed — which is what forced the spec's exceptions and the hash-time sorting. - The fingerprint is a SHA-256 over the plan exactly as planInstall() produced it. Nothing is sorted or merged: a reordered manifest is a different plan, and a server holding the manifest applies the bytes the owner saw. canonicalRequests() and sortedSet() go. - A request's family or action listed twice is refused, as a type listed twice already is, so a manifest has one spelling of what it asks for. - The option is `fingerprint`, not `ifFingerprint`: it is a required approval, not an optional if* precondition, and the versioning.md paragraph that reconciled the two is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
Settles the rule four reviews moved back and forth on: a hash names a canonical form, as schemaHash already does. - The fingerprint hashes what a plan is made from — the manifest, the key, and what planInstall() read from the stack (the install's id, version and linked cards, the key's card, each type's stored version, the linked keys) — not the derived fields or the whole install record. - Each part is canonical: a type by id, name, migratesFrom and schemaHash, a request's actions as a set, every list as a set, keys in one fixed order. A store that reorders keys (jsonb, say) no longer turns every apply into a false StackConflictError. - Canonicalizing happens only when hashing; the manifest is applied as given, as a schema is stored as written. - A repeated action is accepted again, as grantType() accepts it and as actions compare as sets everywhere. A family requested twice is still refused, as a type listed twice is. - Spec: the stale-approval paragraph uses versioning.md's own terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
…on lists - The fingerprint hashes the plan as planInstall() reports it — the manifest, the key, the change lists, newKey, linkedKeys and the existing install's name, version and deleted state — in canonical form. No Record's version is in it, so an edit no plan shows (the owner relabelling an _app card) no longer makes an approval stale, and versioning.md's "version is not conflict detection by itself" holds. PlanInputs, a second description of the plan, goes. - An action list is a set written without repeats, as types, enum values and requested families already are. validateActionList() holds every write of one to it: grantType(), a _grant write, a manifest's requests and an _install write. A stored list is still read as a set. - Restores the test that narrowing a request revokes the actions it drops, and pins that a card relabel leaves a pending approval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
Review: the repeat rule held on some writes and not others, and the same refusal came back in two formats. Both came from validating a manifest's requests and an _install's requests in two places. - validateGrant(baseId, actions, path) returns errors instead of throwing, prefixed with where the grant sits. grantType() throws what it returns. - validateRequests(requests, path) holds a list of requests to one rule — each a grant validateGrant() accepts, each family requested once — and serves both checkManifest() and validateInstall(). An _install's defines refuses a repeated version too. - A manifest's refusals are reported together, each at its path (requests[i].actions[j]), as an _install write reports them. - wire-format.md's 422 row names the refusals apps.md lists, and the fingerprint notes why it hashes foreignRequests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8
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
Before this PR,
installApp()refused a stale plan only by comparing against the plan object it was handed. A server shows the owner a plan in one request and applies it in a later one, holding only the manifest in between, so it had no way to say which plan the owner saw.This gives install approval one primitive, with one comparison rule:
installApp(manifest, { did, fingerprint })replacesinstallApp(plan). It plans the manifest again fordidand refuses withStackConflictErrorunless the result is the plan whosefingerprintit was given. An in-process caller passesplan.fingerprint; a server passes the one the owner saw. The options mirrorplanInstall(manifest, { did }).The fingerprint names the plan the owner saw, in canonical form. It's a SHA-256 over the plan as
planInstall()reports it:newKey;linkedKeys;name,versionand deleted state.The canonical form is the one core's other hashes use, as
schemaHashdoes: a type by itsid,name,migratesFromandschemaHash, every list as a set, and keys in one fixed order. Two consequences:versionis hashed, so an edit no plan shows (the owner relabelling an_appcard) leaves a pending approval standing;One validator per list, used on every write of it.
validateActionList()covers an action list wherever one is written:grantType(), a_grantwrite, a manifest's requests and an_installwrite. It refuses an unknown or repeated action.validateGrant(baseId, actions, path)covers a whole grant: an allowed family, the action list, and read companions for mutate verbs. It returns errors prefixed with where the grant sits;grantType()throws them.validateRequests(requests, path)covers a list of requests: each a grantvalidateGrant()accepts, and each family requested once. Both a manifest and an_installRecord use it. An_install'sdefinesalso refuses a version listed twice.requests[i].actions[j]), the same way an_installwrite reports them.sha256Hex()is the one hashing helper inside@haverstack/core, shared byhashSchema(),planFingerprint()andMemoryAdapter's file IDs.InstallAppOptionsis exported from@haverstack/core.Spec
docs/spec/apps.md:_installrecord: itsrequestsare held to a manifest's rules, and a repeateddefinesentry is refused.versioning.md's split between fixing your input and retrying after re-reading.installApp().docs/spec/access-control.md§ Type-level grants: "actionsis a set, written without repeats".docs/spec/wire-format.md§ Installs: the422row names the refusalsapps.mdlists.docs/spec/versioning.mdis unchanged. Also updated:README.md,packages/core/README.md, and theapps.mdexample, all for the new signature.Verification
pnpm run format:check,check:refs,lint,test,buildandtypecheckall pass.Fingerprint (
install.test.ts):_appcard leaves a pending approval valid;Validation:
requests[0].actions[1]), in an_installwrite, ingrantType()and in a_grantwrite;_installwrite, and a version defined twice in an_installwrite;revokeType().The changeset is a
minoron@haverstack/core.Notes for reviewers
Breaking for callers:
installApp(plan)is gone. In-process callers writeinstallApp(plan.manifest, { did: plan.did, fingerprint: plan.fingerprint }). No other package in this repo calls it.Newly refused writes:
grantType(), a manifest, or an_installwrite;_grantRecord write with an unknown or repeated action;_installwrite whose requests break the grant rules (including a mutate verb without its read companion), repeat a family, or repeat adefinesversion.Over the wire,
POST /recordsnow answers422for the record writes among these.Error paths: a refused manifest request is now reported at
requests[i].…rather than at the grant's ownactions[j]/baseId.🤖 Generated with Claude Code
https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8