Skip to content

feat(core): every installApp() names the plan it approves - #478

Merged
cuibonobo merged 8 commits into
mainfrom
claude/install-app-fingerprint
Nov 2, 2026
Merged

cuibonobo merged 8 commits into
mainfrom
claude/install-app-fingerprint

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

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 }) replaces installApp(plan). It plans the manifest again for did and refuses with StackConflictError unless the result is the plan whose fingerprint it was given. An in-process caller passes plan.fingerprint; a server passes the one the owner saw. The options mirror planInstall(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:

    • the manifest and the key;
    • the change lists and newKey;
    • linkedKeys;
    • the existing install's name, version and deleted state.

    The canonical form is the one core's other hashes use, as schemaHash does: a type by its id, name, migratesFrom and schemaHash, every list as a set, and keys in one fixed order. Two consequences:

    • no Record's version is hashed, so an edit no plan shows (the owner relabelling an _app card) leaves a pending approval standing;
    • a store that reorders keys gets the same fingerprint.
  • One validator per list, used on every write of it.

    • validateActionList() covers an action list wherever one is written: grantType(), a _grant write, a manifest's requests and an _install write. 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 grant validateGrant() accepts, and each family requested once. Both a manifest and an _install Record use it. An _install's defines also refuses a version listed twice.
    • A manifest's refusals are reported together, each at its path (requests[i].actions[j]), the same way an _install write reports them.
    • A stored list is still read as a set, so a grant that reached storage with a repeat still matches.
  • sha256Hex() is the one hashing helper inside @haverstack/core, shared by hashSchema(), planFingerprint() and MemoryAdapter's file IDs.

  • InstallAppOptions is exported from @haverstack/core.

Spec

  • docs/spec/apps.md:
    • § The _install record: its requests are held to a manifest's rules, and a repeated defines entry is refused.
    • § Plan, then apply:
      • "Nothing is applied that was not approved" is one rule: every apply names the plan it approves.
      • "A fingerprint names the plan the owner saw, in canonical form" lists what's hashed.
      • "A stale approval is a conflict" uses versioning.md's split between fixing your input and retrying after re-reading.
      • The refusal list now includes a family requested twice.
      • Residual: the plan-then-apply window inside installApp().
  • docs/spec/access-control.md § Type-level grants: "actions is a set, written without repeats".
  • docs/spec/wire-format.md § Installs: the 422 row names the refusals apps.md lists.

docs/spec/versioning.md is unchanged. Also updated: README.md, packages/core/README.md, and the apps.md example, all for the new signature.

Verification

pnpm run format:check, check:refs, lint, test, build and typecheck all pass.

Fingerprint (install.test.ts):

  • changes with the manifest's name or version and with the key;
  • unchanged when types, requests, actions or enum values are reordered, when keys are reordered inside a schema, or when manifest keys are reversed;
  • relabelling the _app card leaves a pending approval valid;
  • a stale fingerprint is refused and installs nothing, while a current one applies;
  • an approval of one manifest won't apply another.

Validation:

  • a manifest reports every refusal at once, each at its path;
  • a repeated action is refused in a manifest (requests[0].actions[1]), in an _install write, in grantType() and in a _grant write;
  • a family requested twice is refused in a manifest and in an _install write, and a version defined twice in an _install write;
  • narrowing a request revokes the actions it drops;
  • a stored grant naming an action twice is still withdrawn by revokeType().

The changeset is a minor on @haverstack/core.

Notes for reviewers

  • Breaking for callers: installApp(plan) is gone. In-process callers write installApp(plan.manifest, { did: plan.did, fingerprint: plan.fingerprint }). No other package in this repo calls it.

  • Newly refused writes:

    • a manifest requesting one family twice;
    • an action named twice in grantType(), a manifest, or an _install write;
    • a _grant Record write with an unknown or repeated action;
    • an _install write whose requests break the grant rules (including a mutate verb without its read companion), repeat a family, or repeat a defines version.

    Over the wire, POST /records now answers 422 for the record writes among these.

  • Error paths: a refused manifest request is now reported at requests[i].… rather than at the grant's own actions[j] / baseId.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XRRrTNcLcf2TtfuL9wB8Z8

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-bot

changeset-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 993bd14

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

This PR includes changesets to release 10 packages
Name Type
@haverstack/core Minor
@haverstack/adapter-api Patch
@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
@haverstack/wire-types 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

…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
@cuibonobo cuibonobo changed the title feat(core): let installApp() take the fingerprint of the approved plan feat(core): every installApp() names the plan it approves Nov 1, 2026
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
@cuibonobo
cuibonobo merged commit 74ac678 into main Nov 2, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the claude/install-app-fingerprint branch November 2, 2026 01:59
@cuibonobo cuibonobo mentioned this pull request Nov 2, 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.

2 participants