eleven / messenger Public

server+bot: verify app deploys and report real versions (#1398)

A bot could tell a member "✨ Notepad updated (app id: …)" without ever
calling create_app — the model parrots the deterministic confirmation
format (id included, copied from an earlier genuine build in the thread)
and nothing in the pipeline makes the fake distinguishable from the real
thing. And the app id it quotes is meaningless to members: it appears
nowhere in the UI, so it can't be checked against anything.

Make the confirmation a verifiable receipt:

- PUT /api/member-apps/{id} now returns the version the deploy produced
  (putMemberApp already computed it and threw it away).
- The sidecar reads the deployed list back after the PUT and confirms the
  id exists at (at least) that version before announcing anything; a
  mismatch fails the build rather than reporting success.
- The confirmation quotes the verified version — "✨ … updated — version
  3 is live" — and App Studio's list now shows each of your apps' current
  version and app id, so both halves of the receipt cross-check against
  server truth.
- The brain prompt + create_app tool description now say outright: never
  announce a build/update yourself; the system posts the confirmation
  only after the deploy verifies, and no create_app call means no build.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam 58f00a656813bfbedb47ab2b8c37904655d74ecb parent 98b9ba9
8 files changed, +130 −35
  • bot_appbuild_test.go +12 −1
  • changelog/2026-07-31-app-build-verified-versions.md +7 −0
  • db.go +12 −11
  • internal/bot/appbuild.go +60 −13
  • internal/bot/brain.go +4 −1
  • memberapps.go +6 −2
  • memberapps_test.go +21 −5
  • web/static/apps/appstudio.js +8 −2
diff --git a/bot_appbuild_test.go b/bot_appbuild_test.go
index 2ec35e2..eafa5af 100644
--- a/bot_appbuild_test.go
+++ b/bot_appbuild_test.go
@@ -230,7 +230,7 @@ func TestBotBuildsAndDeploysMemberApp(t *testing.T) {
return false
})
replies := botReplies(t, s, dmID, dmKey, "Claude")
- var haveAck, haveID bool
+ var haveAck, haveID, haveVersion bool
for _, rp := range replies {
if strings.Contains(rp.Text, "On it") {
haveAck = true
@@ -238,6 +238,11 @@ func TestBotBuildsAndDeploysMemberApp(t *testing.T) {
if strings.Contains(rp.Text, apps[0].ID) {
haveID = true
}
+ // The confirmation quotes the VERIFIED deployed version (read back from
+ // the server after the PUT), so the member can check it in App Studio.
+ if strings.Contains(rp.Text, "version 1") {
+ haveVersion = true
+ }
}
if !haveAck {
t.Fatalf("expected the model's ack among the replies: %+v", replies)
@@ -245,6 +250,12 @@ func TestBotBuildsAndDeploysMemberApp(t *testing.T) {
if !haveID {
t.Fatalf("expected the deploy confirmation to carry the app id: %+v", replies)
}
+ if !haveVersion {
+ t.Fatalf("expected the deploy confirmation to carry the verified version: %+v", replies)
+ }
+ if apps[0].Version != 1 {
+ t.Fatalf("first deploy should be version 1: %+v", apps[0])
+ }
}
// The model sometimes invents an app_id for a BRAND-NEW app (it did this for
diff --git a/changelog/2026-07-31-app-build-verified-versions.md b/changelog/2026-07-31-app-build-verified-versions.md
new file mode 100644
index 0000000..7f4e1bf
--- /dev/null
+++ b/changelog/2026-07-31-app-build-verified-versions.md
@@ -0,0 +1,7 @@
+🤖 When a bot builds or updates an app for you, its confirmation is now a
+verified receipt: the build system checks the deploy actually landed on the
+server and tells you the app's real version — "✨ Notepad 📝 updated — version
+3 is live". You can cross-check it any time in App Studio, where each of your
+apps now shows its current version and id right on the list. If a bot ever
+*claims* it changed an app without that receipt appearing, nothing was built —
+just ask it to actually do it.
diff --git a/db.go b/db.go
index 3347165..d3e9d91 100644
--- a/db.go
+++ b/db.go
@@ -4698,15 +4698,16 @@ type MemberApp struct {
// bad change, not so many that big blobs pile up unbounded.
const maxMemberAppVersions = 25
-// putMemberApp deploys or updates a member app. A fresh insert is author-owned
-// (owner = author); an update keeps the original author_id AND owner_id
-// (ownership never transfers via an overwrite) and bumps version. Each deploy
-// records the blob in member_app_versions (the history the member can restore).
-// basedOn > 0 marks a revert — the version this deploy's code came from.
-func (db *DB) putMemberApp(id, blob, authorID string, basedOn int) error {
+// putMemberApp deploys or updates a member app and returns the resulting
+// version. A fresh insert is author-owned (owner = author); an update keeps the
+// original author_id AND owner_id (ownership never transfers via an overwrite)
+// and bumps version. Each deploy records the blob in member_app_versions (the
+// history the member can restore). basedOn > 0 marks a revert — the version
+// this deploy's code came from.
+func (db *DB) putMemberApp(id, blob, authorID string, basedOn int) (int, error) {
tx, err := db.sql.Begin()
if err != nil {
- return err
+ return 0, err
}
defer tx.Rollback()
now := time.Now().Unix()
@@ -4716,16 +4717,16 @@ func (db *DB) putMemberApp(id, blob, authorID string, basedOn int) error {
"version = member_apps.version + 1, updated_at = excluded.updated_at",
id, blob, authorID, authorID, now, now,
); err != nil {
- return err
+ return 0, err
}
var version int
if err := tx.QueryRow("SELECT version FROM member_apps WHERE id = ?", id).Scan(&version); err != nil {
- return err
+ return 0, err
}
if err := recordMemberAppVersion(tx, id, version, blob, authorID, now, basedOn); err != nil {
- return err
+ return 0, err
}
- return tx.Commit()
+ return version, tx.Commit()
}
// recordMemberAppVersion writes one version row (basedOn > 0 records the version
diff --git a/internal/bot/appbuild.go b/internal/bot/appbuild.go
index 814c213..da5d98c 100644
--- a/internal/bot/appbuild.go
+++ b/internal/bot/appbuild.go
@@ -167,7 +167,10 @@ func createAppTool() anthTool {
"understand what they want (at most one or two clarifying questions: the core thing being " +
"tracked and its lifecycle). Then call with a precise spec. The build takes about a minute " +
"and you will post the result to the thread when it finishes — your accompanying text should " +
- "just say you've started.",
+ "just say you've started. NEVER announce an app as built or updated yourself: the ✅/✨ " +
+ "confirmation (naming the app's new version) is posted by the build system only after the " +
+ "deploy is verified on the server. If you did not call this tool, nothing was built, and " +
+ "writing a confirmation-style message would be false.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -668,11 +671,26 @@ func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, request
id = "app-" + hex.EncodeToString(buf[:])
verb = "built"
}
- if err := r.putMemberApp(id, blob, requesterUID); err != nil {
+ version, err := r.putMemberApp(id, blob, requesterUID)
+ if err != nil {
fail(fmt.Sprintf("the deploy was rejected (%v).", err))
return
}
- r.logf("app build %q: deployed as %s (%d types, %d bytes)", req.Name, id, len(types), len(source))
+ // Double-check before telling the member anything: read the deployed list
+ // back and confirm the app really is there at the version the PUT reported.
+ // The success message quotes that verified version (App Studio shows the
+ // same number), so "✨ … version N is live" is a checkable claim — never a
+ // report written on the strength of a 200 alone.
+ listed, err := r.deployedAppVersion(id)
+ if err != nil {
+ fail(fmt.Sprintf("the deploy didn't verify — I couldn't read the app back (%v).", err))
+ return
+ }
+ if listed == 0 || (version > 0 && listed < version) {
+ fail(fmt.Sprintf("the deploy didn't verify — the server lists version %d, expected %d.", listed, version))
+ return
+ }
+ r.logf("app build %q: deployed as %s version %d (%d types, %d bytes)", req.Name, id, listed, len(types), len(source))
// New apps register live, so it's usable straight away; an update replaces
// code the app already loaded, so it needs a reload. Either way we point at
// the gentle Reload offer rather than implying a forced refresh.
@@ -680,9 +698,9 @@ func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, request
if verb == "updated" {
pickup = "Tap Reload in the sidebar (or whenever you're ready) to pick up the changes — no rush."
}
- settle(fmt.Sprintf("✅ %s %s %s.", req.Title, req.Glyph, verb),
- fmt.Sprintf("✨ %s %s %s. %s Want changes? Just describe them here. (app id: %s)",
- req.Title, req.Glyph, verb, pickup, id))
+ settle(fmt.Sprintf("✅ %s %s %s (version %d).", req.Title, req.Glyph, verb, listed),
+ fmt.Sprintf("✨ %s %s %s — version %d is live. %s Want changes? Just describe them here. (app id: %s)",
+ req.Title, req.Glyph, verb, listed, pickup, id))
}
// generateApp is the dedicated build call (+ regenerate-on-validation-failure
@@ -858,24 +876,53 @@ func uniqueAppName(name string, taken map[string]bool) string {
// putMemberApp deploys the encrypted blob with the bot's member token, recording
// the requester as the app's owner so it's theirs to manage (App Studio
-// view/edit/remove) even though the bot deployed it.
-func (r *Runtime) putMemberApp(id, blob, ownerID string) error {
+// view/edit/remove) even though the bot deployed it. Returns the version the
+// server reports for this deploy (0 against an older server that doesn't say).
+func (r *Runtime) putMemberApp(id, blob, ownerID string) (int, error) {
body, err := json.Marshal(map[string]string{"token": r.Session.Token, "blob": blob, "owner_id": ownerID})
if err != nil {
- return err
+ return 0, err
}
req, err := http.NewRequest("PUT", r.Base+"/api/member-apps/"+id, strings.NewReader(string(body)))
if err != nil {
- return err
+ return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
- return err
+ return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("server %d", resp.StatusCode)
+ return 0, fmt.Errorf("server %d", resp.StatusCode)
+ }
+ var out struct {
+ Version int `json:"version"`
+ }
+ // Tolerate a version-less response (older server): verification then only
+ // requires the app to be listed at all.
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return out.Version, nil
+}
+
+// deployedAppVersion returns the version the server currently lists for the
+// given app id — 0 when the id isn't deployed at all. This is the read-back
+// behind runAppBuild's verification: no metadata endpoint exists on purpose
+// (the list is the only surface), so it re-fetches the list like any client.
+func (r *Runtime) deployedAppVersion(id string) (int, error) {
+ var list struct {
+ Apps []struct {
+ ID string `json:"id"`
+ Version int `json:"version"`
+ } `json:"apps"`
+ }
+ if err := r.get("/api/member-apps", &list); err != nil {
+ return 0, err
+ }
+ for _, a := range list.Apps {
+ if a.ID == id {
+ return a.Version, nil
+ }
}
- return nil
+ return 0, nil
}
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index 3913f4e..42bdda1 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -166,7 +166,10 @@ func (r *Runtime) systemPrompt() string {
"understand the core thing being tracked and its lifecycle — at most one or two short " +
"clarifying questions, then build; don't interrogate. Call create_app with a precise spec; " +
"the build runs in the background and you post the result when it's done, so your reply " +
- "alongside the call should only say you've started. To change an app you built earlier, " +
+ "alongside the call should only say you've started. Never claim an app was built or " +
+ "updated yourself — the build system posts the real confirmation (with the app's new " +
+ "version number) once the deploy is verified, and a build or update happens ONLY through " +
+ "a create_app call, never through your text. To change an app you built earlier, " +
"call create_app again with its app_id from your earlier reply. Apps are personal to the " +
"member who asked and appear in their Apps menu (+ New chat). An app can also offer slash " +
"commands for quick capture from any chat (e.g. /habit water) and a companion chat view where " +
diff --git a/memberapps.go b/memberapps.go
index cfd37f5..d91a1ac 100644
--- a/memberapps.go
+++ b/memberapps.go
@@ -128,12 +128,13 @@ func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ version := 1
if exists {
if existing.AuthorID != uid && existing.OwnerID != uid {
httpError(w, http.StatusForbidden, "only the app's author or owner may update it")
return
}
- if err := s.db.putMemberApp(id, body.Blob, uid, body.BasedOn); err != nil {
+ if version, err = s.db.putMemberApp(id, body.Blob, uid, body.BasedOn); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
@@ -159,7 +160,10 @@ func (s *Server) putMemberAppHandler(w http.ResponseWriter, r *http.Request) {
kind = "updated"
}
s.broadcastAppsChanged(kind)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
+ // The version lets the deployer verify (and tell the member) exactly which
+ // version this deploy produced — the bot sidecar reads it back before it
+ // announces a build as done.
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "version": version})
}
// deleteMemberAppHandler removes a member app: its author or owner retiring their
diff --git a/memberapps_test.go b/memberapps_test.go
index 59244c3..f9f6cfd 100644
--- a/memberapps_test.go
+++ b/memberapps_test.go
@@ -42,9 +42,20 @@ func TestMemberAppsCRUDAndGates(t *testing.T) {
if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":""}`, tok1)); r.StatusCode != http.StatusBadRequest {
t.Fatalf("empty blob: want 400, got %d", r.StatusCode)
}
- if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok1)); r.StatusCode != http.StatusOK {
+ // A deploy reports the version it produced — the read-back the bot sidecar
+ // verifies against before announcing a build.
+ var put struct {
+ OK bool `json:"ok"`
+ Version int `json:"version"`
+ }
+ r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-1"}`, tok1))
+ if r.StatusCode != http.StatusOK {
t.Fatalf("deploy: want 200, got %d", r.StatusCode)
}
+ decodeJSON(t, r, &put)
+ if !put.OK || put.Version != 1 {
+ t.Fatalf("create should report version 1: %+v", put)
+ }
// The list returns the blob and the author.
r, _ = http.Get(ts.URL + "/api/member-apps?token=" + tok2)
@@ -57,9 +68,14 @@ func TestMemberAppsCRUDAndGates(t *testing.T) {
if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"evil"}`, tok2)); r.StatusCode != http.StatusForbidden {
t.Fatalf("non-author overwrite: want 403, got %d", r.StatusCode)
}
- if r := doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok1)); r.StatusCode != http.StatusOK {
+ r = doReq(t, "PUT", ts.URL+"/api/member-apps/app-abc123", fmt.Sprintf(`{"token":"%s","blob":"ciphertext-2"}`, tok1))
+ if r.StatusCode != http.StatusOK {
t.Fatalf("author update: want 200, got %d", r.StatusCode)
}
+ decodeJSON(t, r, &put)
+ if put.Version != 2 {
+ t.Fatalf("update should report version 2: %+v", put)
+ }
app, ok, _ := s.db.getMemberApp("app-abc123")
if !ok || app.Blob != "ciphertext-2" || app.Version != 2 || app.AuthorID != uid1 {
t.Fatalf("after update: %+v ok=%v", app, ok)
@@ -243,7 +259,7 @@ func TestMemberAppsDeployCap(t *testing.T) {
ts, s := newTestServer(t)
tok, uid, _ := joinedMember(t, s, "Ana")
for i := 0; i < maxMemberApps; i++ {
- if err := s.db.putMemberApp(fmt.Sprintf("app-cap-%03d", i), "b", uid, 0); err != nil {
+ if _, err := s.db.putMemberApp(fmt.Sprintf("app-cap-%03d", i), "b", uid, 0); err != nil {
t.Fatal(err)
}
}
@@ -263,7 +279,7 @@ func TestMemberAppsPerAuthorCap(t *testing.T) {
tokA, uidA, _ := joinedMember(t, s, "Ana")
tokB, _, _ := joinedMember(t, s, "Bo")
for i := 0; i < maxMemberAppsPerAuthor; i++ {
- if err := s.db.putMemberApp(fmt.Sprintf("app-ana-%03d", i), "b", uidA, 0); err != nil {
+ if _, err := s.db.putMemberApp(fmt.Sprintf("app-ana-%03d", i), "b", uidA, 0); err != nil {
t.Fatal(err)
}
}
@@ -289,7 +305,7 @@ func TestMemberAppsConcurrentCap(t *testing.T) {
// Seed to one below the global cap, then fire many concurrent new deploys.
for i := 0; i < maxMemberApps-1; i++ {
uid, _ := s.db.deviceTokenUser(tokens[i%len(tokens)])
- if err := s.db.putMemberApp(fmt.Sprintf("app-seed-%03d", i), "b", uid, 0); err != nil {
+ if _, err := s.db.putMemberApp(fmt.Sprintf("app-seed-%03d", i), "b", uid, 0); err != nil {
t.Fatal(err)
}
}
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index 6fabe39..3466f0b 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -280,14 +280,20 @@ export const app = defineApp({
}
if (a.mine) {
const extra =
+ // Version + id let the member cross-check a bot's build report
+ // ("✨ … version 3 is live … (app id: app-…)") against what's
+ // actually deployed — the numbers here are the server's, not the
+ // bot's.
+ ` · version ${a.version || 1}` +
(m.chat ? " · chat view" : "") +
((m.places ? m.places.includes("chat") : m.groupCapable) ? " · group-capable" : "") +
((m.commands || []).length ? " · " + m.commands.map((c) => c.name).join(" ") : "") +
// The legacy-format flag (docs/member-apps.md § Legacy formats):
// the app still loads fine, but its stored manifest predates the
// places vocabulary — the Upgrade button below modernizes it.
- (manifestIsLegacy(m) ? " · older format" : "");
- if (extra) by.appendChild(document.createTextNode(extra));
+ (manifestIsLegacy(m) ? " · older format" : "") +
+ ` · ${a.id}`;
+ by.appendChild(document.createTextNode(extra));
}
meta.appendChild(by);
row.appendChild(meta);