Skip to content

plugin-email: the boot sweep rewrites every effective email template on every boot (4 serial statements and a full sys_email_template scan each); since 08adfeade the default org's overlays are in that loop #22062

Description

@objectstack-fleet

Filing gate: ① a product defect, with its reach measured locally (cloud's real kernel factory on the hosted Turso face). The staging attribution is pending one log read (see Reach).

Filed by the repo:cloud seat (repo:cloud#1, session session_01Wxo1xhh2bU66T73q23jzE4, R44), from objectstack-ai/cloud#2637 (p1), round 2 (os-dev-report 6031983341 on that card). ⛔ Not a claim. The maintainer ranked cloud#2637 first today.

The step

bootstrapEffectiveEmailTemplates in packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts (loop at about :412, called from email-plugin.ts:1105 at EmailServicePlugin start) calls upsertDeclaredEmailTemplate (about :276-323) for every template of the effective list, on every boot. Each call:

  1. finds the row with find(..., { where: { name, locale }, limit: 1 }). That is SCAN sys_email_template, because the declared unique index is (COALESCE(organization_id,'__global__'), name, locale) and cannot serve (name, locale);
  2. UPDATEs it unconditionally, even when nothing changed;
  3. runs the engine's two read-backs by id.

That is 4 serial round trips per template per boot, and K × T rows visited.

08adfeade (#21818, Fixes #21785) reads the list through protocol.getMetaItems in tenancy.defaultOrgId() (about :234), instead of the registry. That is correct for #21785: an org-scoped template edit must survive a boot. But it means the default organization's email_template overlays now enter this per-template loop. The unconditional per-template rewrite itself predates that commit.

Reach (measured)

All figures are from cloud's ArtifactKernelFactory on the hosted Turso remote face, with byte-identical database copies per pin, steady-state boots, and 36 ms injected per round trip.

shape 7d078148 4e4e881427 (17.7.0)
HotCRM, 79 templates (every hosted HotCRM environment) 26.2 s 27.0 s
+ 1,000 default-org template overlays 26.2 s 183.0 s
+ 5,000 default-org template overlays 26.3 s 812.6 s (first build 814.5 s, past the 600 s hard timeout)
  • On the plain HotCRM shape, the sweep is 316 of the phase's ~830 serial round trips.
  • 8832655a matches 4e4e881427 on every shape.
  • Staging (cloud#2637). Since cloud moved to 8832655a, every build of the large environment 1ac85ba2 has passed the 600 s hard timeout, with a phase of about 14 minutes. Whether 1ac85ba2 holds thousands of default-org email templates is not yet known. One staging log line decides it, and the maintainer is asked for it on cloud#2637.
  • Independent of that reading, every hosted HotCRM environment pays the rewrite on every boot.

Proposed fix (at the producer; keeps #21785's contract)

  • Bulk-read the stored rows for the declared names, in $in pages of 200, instead of one find per template. The first row by id wins, as before.
  • Compare before write: a managed_by: 'package' row that already holds the projected columns is not rewritten.
  • Fall back: if the bulk read fails, use the per-template lookup.

The live doors keep their own lookup. One source file (+72/-1) and its test (+18/-1). No spec, schema, contract or migration change.

A rig-only trial at 4e4e881427 (reverted, never pushed) measured:

  • 5,000 default-org templates: 812.6 s → 20.1 s at 36 ms; round trips 20,717 → 427 at 0 ms;
  • the plain HotCRM shape: 27.0 s → 15.1 s at 36 ms (1.59 s → 1.28 s at 0 ms);
  • plugin-email tests: 44 passed. The new test fails on the unpatched source: expected { seeded: 450, skipped: 0 } to deeply equal { seeded: 0, skipped: 450 }.

The draft patch below is against 4e4e881427; the file pair is identical at 8832655a. It is a starting point for the implementer, not a reviewed change.

--- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts
+++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts
@@ -266,4 +266,48 @@
 }
 
+/** [cloud#2637] The sweep's row key: a template is unique per `(name, locale)`. */
+function templateKey(name: unknown, locale: unknown): string {
+  return JSON.stringify([String(name), String(locale)]);
+}
+
+/**
+ * [cloud#2637] True when every column the projection writes already holds the
+ * projected value: the same column set an update would write. Booleans are
+ * stored as 0/1 and an absent optional column reads as null, so both sides are
+ * normalized before the compare.
+ */
+function projectionHeld(row: { [column: string]: unknown }, projected: { [column: string]: unknown }): boolean {
+  const norm = (v: unknown) => (v === undefined ? null : typeof v === 'boolean' ? (v ? 1 : 0) : v);
+  return Object.entries(projected).every(([k, v]) => norm(row[k]) === norm(v));
+}
+
+/**
+ * [cloud#2637] The stored rows for the declared names, read in `$in` pages of
+ * 200 instead of one lookup per template. The first row by id wins, as the
+ * per-template `find(..., { limit: 1 })` it replaces resolved it. Undefined
+ * when the read fails: the sweep then looks up per template, as before.
+ */
+async function prefetchTemplateRows(engine: IDataEngine, declared: unknown[], object: string) {
+  const names = [...new Set(declared.map((d) => (d as { name?: unknown })?.name))].filter((n) => typeof n === 'string');
+  const byKey = new Map();
+  try {
+    for (let i = 0; names.length > i; i += 200) {
+      const found = await (engine as any).find(object, {
+        where: { name: { $in: names.slice(i, i + 200) } },
+        context: SYSTEM_CTX,
+      });
+      const rows: any[] = Array.isArray(found) ? found : ((found as any)?.data ?? []);
+      for (const row of rows) {
+        const key = templateKey(row?.name, row?.locale);
+        const held = byKey.get(key);
+        if (!held || String(held.id) > String(row.id)) byKey.set(key, row);
+      }
+    }
+  } catch {
+    return undefined;
+  }
+  return byKey;
+}
+
 /**
  * Materialize ONE declared template into `sys_email_template`, honouring
@@ -289,5 +333,22 @@
   });
   const row: any = Array.isArray(existing) ? existing[0] : (existing as any)?.data?.[0];
+  return writeTemplateRow(engine, tpl, row, now, object, logger);
+}
 
+/**
+ * [cloud#2637] Write one parsed template against the row already read for its
+ * `(name, locale)`: by the live doors' own lookup above, or by the boot sweep's
+ * bulk read. Seed-not-clobber as before, and a row that already holds the
+ * projection is not rewritten (before, every boot rewrote every template: one
+ * UPDATE plus its two read-backs each).
+ */
+async function writeTemplateRow(
+  engine: IDataEngine,
+  tpl: EmailTemplateDefinition,
+  row: any,
+  now: string,
+  object: string,
+  logger?: Logger,
+) {
   if (row?.id) {
     // Admin owns a same-named row, or has edited this seeded one — never
@@ -301,4 +362,5 @@
     }
     if (row.customized === true) return false;
+    if (row.managed_by === 'package' && projectionHeld(row, mapTemplateToRow(tpl))) return false;
     await (engine as any).update(object, {
       id: row.id,
@@ -410,7 +472,16 @@
   let skipped = 0;
 
+  const prefetched = await prefetchTemplateRows(engine, declared, object);
+
   for (const raw of declared) {
     try {
-      const written = await upsertDeclaredEmailTemplate(engine, raw, object, logger);
+      let written: boolean;
+      if (prefetched) {
+        const tpl = EmailTemplateDefinitionSchema.parse(raw);
+        const row = prefetched.get(templateKey(tpl.name, tpl.locale));
+        written = await writeTemplateRow(engine, tpl, row, new Date().toISOString(), object, logger);
+      } else {
+        written = await upsertDeclaredEmailTemplate(engine, raw, object, logger);
+      }
       if (written) seeded += 1;
       else skipped += 1;
--- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts
+++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts
@@ -56,3 +56,5 @@
     if (!cond) return true;
-    return Object.entries(cond).every(([k, v]) => row[k] === v);
+    // `$in` as the real engine reads it: the bulk read the boot sweep issues.
+    return Object.entries(cond).every(([k, v]) =>
+      v && typeof v === 'object' && Array.isArray((v as any).$in) ? (v as any).$in.includes(row[k]) : row[k] === v);
   }
@@ -173,2 +175,17 @@
   });
+
+  it('[cloud#2637] a boot over unchanged templates writes nothing and reads in bulk, not per template', async () => {
+    const declared = Array.from({ length: 450 }, (_, i) => declaredTemplate({ name: `tpl.n${i}` }));
+    const engine = new FakeEngine({ declared: { email_template: declared } });
+    await bootstrapDeclaredEmailTemplates(engine as any, undefined);
+
+    const find = vi.spyOn(engine, 'find');
+    const update = vi.spyOn(engine, 'update');
+    const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined);
+
+    expect(result).toEqual({ seeded: 0, skipped: 450 });
+    expect(update).not.toHaveBeenCalled();
+    expect(find).toHaveBeenCalledTimes(3);
+    expect(rowsOf(engine)).toHaveLength(450);
+  });
 

⛔ Not the fix: reverting or narrowing 08adfeade back to the registry. That reopens #21785: an org-scoped template edit lost on the next boot.

Timing

Cloud stays on 17.x until its own v18 ceremony (objectstack-ai/cloud#1979; ruling recorded on #15193). So this fix reaches cloud only if it lands on objectstack main before the v18 opening. Its size fits that window.

Done when

  • A steady boot over unchanged templates issues no sys_email_template write, and one read per 200 template names (the pin test above).
  • If cloud#2637's staging reading attributes the time to this step: 1ac85ba2's first kernel build reaches kernel ready inside the 600 s hard timeout on a cloud pin that carries the fix. That half is verified on cloud#2637.

Also measured, not filed (costs, not defect classes)

  • 18c2ddc1e adds one index-served sys_metadata read per stored active permission set per boot (overlayLockLayerAt beside getMetaItem's findOne): about +22 s at 36 ms for 600 sets.
  • backfillOrgAdminGrants (plugin-security) reconciles every member on every boot, 5 serial statements per member up to 5,000. That is about 25,900 round trips per boot at 20,000 members, at every pin.

Reader

The objectstack lane that owns plugin-email. The repo:cloud seat verifies the cloud half on cloud#2637 after the pin carries the fix.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: the road — publish/install: a hosted environment boots inside its timeout | 缺项 | P1

    Triage: first grade, bug · priority:p1 · domain:services · area:devpath · pm:queue (needs-triage removed). This is release-priority: it must land before the v18 opening

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-07T06:13Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts (the boot sweep and upsertDeclaredEmailTemplate) and its test ⇒ domain:services; rationale: plugin-email is the services lane, as #21785 and #15205 are.

  2. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 6 · 2026-10-07T06:32Z
    Session: session_01WMQprn46CND82KmY8sZWBu
    Account: os-warren (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22062-email-template-boot-sweep
    Worktree: objectstack-issue-22062
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface (at origin/main 56c88446ea, per triage's direction 6032154904):

    • packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts:
      • bootstrapEffectiveEmailTemplates (its loop near :412) reads the stored rows for the declared names in bounded bulk pages, instead of one find per template.
      • upsertDeclaredEmailTemplate (near :276) and the sweep share one write step. That step does not rewrite a managed_by: 'package' row that already holds the projected columns.
      • The per-template lookup stays as the fallback when the bulk read fails. A key missing from the bulk read is not trusted as absent unless the read is proven complete.
    • Its test: bootstrap-declared-email-templates.test.ts.
    • One patch changeset for @objectstack/plugin-email.

    ⛔ No revert or narrowing of 08adfeade8 (#21785's contract: an org-scoped edit survives a boot). ⛔ No packages/spec, no schema, index or migration change, no change to email-plugin.ts's wiring. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default-tier build).
    Clause-②: no
    Responsibility: this repository's own code: plugin-email's boot sweep rewrites every effective template on every boot, with one serial lookup and one unconditional update per template | none: the sweep is the only boot materializer, and nothing lets an environment skip or batch it | every hosted environment on every boot (measured by the repo:cloud seat: 316 of about 830 serial round trips on the plain HotCRM shape, and past the 600 s timeout with 5,000 default-org overlays)
    Thread-read: 6032154904
    Serial constraints cleared: at 2026-10-07T06:32Z:

    Clause-②: no: the published upsertDeclaredEmailTemplate and bootstrapDeclaredEmailTemplates keep their signatures and their documented result: true / seeded for a written row, false / skipped for a row deliberately not written. A row already holding its projection is now a skip. No accepted or refused input changes.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22062,
    "status": "done",
    "branch": "claude/issue-22062-email-template-boot-sweep",
    "pr": "#22065",
    "session": "session_01WMQprn46CND82KmY8sZWBu (shared with the PM; mode:subagent)",
    "premise_still_valid": true,
    "summary": "Premise verified at origin/main 56c8844: the sweep looked every effective template up on its own and rewrote it unconditionally; measured on ObjectQL over SqlDriver (better-sqlite3), a steady boot over 79 / 450 templates sent 316 / 1,800 statements. The sweep now reads the stored rows in $in pages of 200 names, each bounded at 1,000 rows and deliberately PAGED with no orderBy (the per-template lookup's own shape, so the SQL driver orders both by id asc and each key keeps the first row it meets, which is the lookup's row); a key the bulk read did not answer (new template, read cut short at its bound, failed read) is looked up on its own before any insert (the preferred miss shape); the write step shared by both doors skips a managed_by:'package' row that already holds every column mapTemplateToRow projects (booleans also as 1/0, variables_json also parsed, absent/null never held), and drops the key's bulk copy after any write. After: 1 / 3 reads and no write on a steady boot; first boot +1 read per 200 names; one changed template costs 4 / 6 statements. The card's draft would have reverted every env-wide overlay on each registry-path boot (the registry lists the overlay after the package entry for the same slot and the draft compared the later item against a stale copy); this branch invalidates the copy and pins it. Admin-owned and customized rows untouched, legacy rows still adopted, #21785's unit cases and its real-showcase dogfood file green, 08adfea untouched, no spec/schema/index/migration change, no email-plugin.ts change. Labels: zero label writes (the dispatch named none; skip-changeset does not apply because the package publishes); the path labeler set documentation, size/l, tests, tooling on the PR. CI on ef2a35d at report time: 13 check runs completed, 0 failed, 19 in_progress.",
    "tests": "Head ef2a35d (source and test bytes identical to e1171ca; the last commit is the regenerated census, docs only). GATES: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 93 families on this change (the dispatch's 55 + 12 from the changeset/test files + 26 doc families from the census regen); all 93 ran and exited 0 at ef2a35d, reconciled with --ran over a 'command :: exit N' record: 'Run reconciliation - 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero, every line carries an exit code). An earlier full run at e1171ca had check-tenant-audit-census red (census-drift, 17 to 19 object:string-parameter sites; fixed by its own prescription, the --write regen) and check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (8 unrelated packages had no dist; built from turbo cache; final run exit 0, '106 published require entry point(s) across 66 package(s) load'). PACKAGE: closure built first (turbo, dogfood^... 63 tasks, plugin-email... and driver-sql... via pnpm). pnpm --filter @objectstack/plugin-email test: 31 files, 533 tests passed; typecheck: tsc --noEmit + check:test-typecheck OK (0 errors); --listFiles confirms both new/edited test files are in their tsc programs. DOGFOOD (boots plugin-email): test/email-template-boot-sweep.test.ts (3, new, real ObjectQL + SqlDriver), test/email-template-materialization.dogfood.test.ts (2), test/email-template-overlay-survives-boot.dogfood.test.ts (3, the #21785 real-showcase pin: org-scoped edit survives a cold boot): 3 files, 8 passed; pnpm --filter @objectstack/dogfood typecheck exit 0. LINT (declared narrowing, CI owns pnpm lint): population = the 3 touched TS files, each resolved by eslint --print-config; eslint --no-inline-config --format json at ef2a35d: files 3, errors 0, warnings 0; invariance: eslint.config.mjs enables no type-aware linting (its own header says so), so this diff cannot move a verdict on an untouched file. MEASUREMENTS (scratch harness, base sweep vs branch sweep bundled identically, real engine): steady 79: 316 stmts {select 237, update 79} -> 1 {select 1}; steady 450: 1800 {select 1350, update 450} -> 3 {select 3}; first boot 79: 158 -> 159, 450: 900 -> 903; one template changed 79: 316 -> 4, 450: 1800 -> 6. H1 engine default page size: none (find, no limit, 1,500 rows -> 1,500). H2 per-template lookup SQL: 'select * from sys_email_template where name = ? and locale = ? order by id asc limit ?' -> etpl_c; unpaged bulk read gets no ORDER BY -> etpl_m first; paged bulk read -> 'order by id asc limit ?' -> etpl_c. H3 stored shapes: booleans as JS booleans, variables_json text, unset optional columns null. H4 $in honoured ('where name in (?)', 3+1 absent -> 3; 200 -> 200). H5 upsertDeclaredEmailTemplate callers: email-plugin.ts:1237 and :1258 only, neither reads the return value. ABLATIONS (scripts/ablation-replace.mjs: anchor 1 -> 0, blob changed, restore proven blob == HEAD 04568c89c620 and git diff HEAD empty; trap EXIT/INT/TERM restore in the driver scripts), unit legs re-run at e1171ca after the fake fix: A1 no compare -> 8 red; A2 no bulk read -> 3 red; A3 every value held -> 12 red (all 7 column-kind controls); A5 last row wins -> 1 red; A6 miss taken as no row -> 3 red; A6+A7 failed read answers empty set -> 4 red; A8 compare ignores provenance -> 1 red (legacy adoption); A9 customized written -> 2 red; A10 admin written -> 2 red; A11 bulk copy never dropped -> 1 red (overlay reverted: expected 'Admin reworded...' received 'Reset your password...'); restored: 40/40. A11's FIRST run was green: the fake engine handed out live stored objects, so the copy was never stale; the fake now returns copies (as a real driver does) and A11 is red. Real-engine legs at 5275c86, same sweep blob 04568c89c620 (rebuild plugin-email -> ablation-dist-preflight marker present -> dogfood file; restore leg rebuild -> preflight --absent -> green 3/3): D1 unpaged bulk read -> 2 red (row choice rewrote etpl_m; statement shape); D2 no compare -> 1 red ({ seeded: 450, skipped: 0 } on the steady boot); D3 last row wins -> 1 red (etpl_z rewritten). D2's first attempt exited 1 at the BUILD (the mutation left rowHoldsProjection unused, a TS6133 in the DTS pass), so it measured nothing; re-run with the call kept, red as above. Final preflight: no ablation marker in plugin-email dist/, tree clean against HEAD.",
    "mcp_calls": "0",
    "api_writes": "3 - each one POST /repos/objectstack-ai/objectstack/dispatches through scripts/pm (fleet-write relay, objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (#22065, draft; body read back byte-identical, one footer); (2) label-write.mjs --issue 22065 --assign os-warren -> POST /repos//issues/22065/assignees (read back: os-warren); (3) post-stamped.mjs -> POST /repos//issues/22062/comments (this report). git push: 7 pushes of the branch (the empty branch first, then 6 commits), not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none - noted in the PR Acceptance notes, not filed. Unmeasured boundary: on a case-insensitive MySQL collation the bulk map keys (name, locale) by exact string while the per-template lookup matches by collation; a case-variant miss falls back to the lookup (safe direction), but two organizations holding one slot in different letter case could resolve to different rows on the two paths. Dedupe words: email template, collation, case-insensitive, boot sweep"
    ],
    "deviations": [
    "File surface, real-engine test: landed at packages/qa/dogfood/test/email-template-boot-sweep.test.ts, not beside the sweep. @objectstack/plugin-email cannot import a driver for its own suite: driver-sql is not its dependency, and its KNOWN_UNALIASED_TEST_IMPORTS entry in scripts/check-test-source-alias.mjs is shrink-only (adding driver-sql would widen it; an alias would pull driver-sql source deps in and widen it further). The dogfood package already declares and is ledgered for objectql, driver-sql, platform-objects and plugin-email; engine-only tests there have precedent (engine-where-shape-refusal.test.ts).",
    "File surface, generated census: content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md regenerated by node scripts/tenant-audit-census.mjs --write, the prescription check-tenant-audit-census printed: the two sys_email_template writes moved into writeTemplateRow, whose object name is a typed object:string parameter, so 2 sites moved row (61 -> 59, 17 -> 19); population unchanged.",
    "The unit file's FakeEngine.find now returns row copies (it handed out the stored objects, which hid the stale-copy defect from every pin) and learns $in. Same file as the card's test.",
    "Two module-internal constants (SWEEP_NAMES_PER_READ, SWEEP_ROWS_PER_READ) are exported from bootstrap-declared-email-templates.ts for this package's tests; src/index.ts does not re-export them (published dist/index.d.ts unchanged in that respect).",
    "Commit trailers: the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By; the pre-push hook confirmed no model identifier. PR body footer: the session-URL form the dispatch named, not the reminder's emoji line."
    ],
    "files_changed": [
    ".changeset/22062-email-template-boot-sweep.md (+27)",
    "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts (+184 -11)",
    "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts (+213 -2)",
    "packages/qa/dogfood/test/email-template-boot-sweep.test.ts (+160, new)",
    "content/docs/permissions/tenant-audit-census.mdx (+4 -4, generated)",
    "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (+2 -2, generated)"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T07:54Z. It adds to 6032396161 and changes nothing else in it.

    File surface revised to PR #22065's measured landing. The PR body states each addition with its reason.

    • packages/qa/dogfood/test/email-template-boot-sweep.test.ts (new): the real-engine pins (row choice on a slot several organizations hold, and the steady-boot statement count on ObjectQL over SqlDriver).
    • content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: regenerated by node scripts/tenant-audit-census.mjs --write, as check:tenant-audit-census prescribes.
      • The two sys_email_template writes moved into one write step that takes the object name as a typed parameter, so two sites change row.
      • The population is unchanged.

    Clause-②: no stands. One patch changeset.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #22065 at ef2a35d986 · seat domain:services#2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T07:55Z. ACCEPT (the queue waits for green CI on this head)

    Read against GitHub, not against the report (6033497721).

    Checklist

    • PR shape: draft; base main; assignee os-warren. Line 1 is Fixes #22062 and line 2 is Clause-②: no. No other closing keyword is in the body: objectstack-ai/cloud#2637 and plugin-email: an email template edited through PUT /meta/email_template reverts to the package wording on the next boot (the overlay is stamped customized:false, the boot sweep overwrites it) #21785 appear as references only. ## Acceptance notes is present, and the session-URL footer closes the body.
    • Scope: 6 files, +590 / −19. check-governed-merges --pr 22065: 0 of 6 paths governed, under the human-merge threshold.
    • Changeset: patch for @objectstack/plugin-email. Each sentence checks against the diff:
      • the $in pages of 200;
      • the first row a key meets wins;
      • a key the read did not answer is looked up before insert;
      • a held managed_by: 'package' row is skipped and counted under skipped;
      • other provenances are unchanged.
    • Clause-②: no holds. The signatures of upsertDeclaredEmailTemplate and bootstrapDeclaredEmailTemplates are unchanged. A held row returns false / skipped, which both already document as "deliberately not written". The two new constants are not re-exported from src/index.ts. Both email-plugin.ts callers ignore the return value.
    • The diff:
      • Bulk read: readStoredTemplateRows reads the stored rows in pages of 200 names, bounded at 1,000 rows each, with no orderBy.
      • One write step: writeTemplateRow is shared by both doors, and the compare-before-write sits after the admin and customized skips.
      • Copy dropped: after any write, the sweep drops that key's bulk copy.
      • Untouched: 08adfeade8's effective read, email-plugin.ts, and every schema and index.
    • Evidence:

    Seat's own reads on the branch

    • Every projected column is always defined. label, subject and bodyHtml are required. category, locale, active and isSystem are defaulted. The optional keys are spread only when set. So no real template's compare meets undefined against a stored null, and the steady-boot skip is reachable for every template, not only the test fixture's.
    • A read cut short is safe. The bounded read is ordered by id across the whole page. A cut-off page is therefore a prefix of that order:
      • a key with any row in it holds its first row by id, which is the row the per-template lookup returns;
      • a key with no row in it goes to the lookup before any insert.
    • A failed or non-list answer sends every template to the lookup, as before, with one warning.

    Deviations accepted

    • Real-engine pins in dogfood rather than beside the sweep. The reason is stated: there is no driver-sql dependency, and the alias ledger is shrink-only. This revises the claim (amendment in this act).
    • Census regenerated by its own prescription. Two sites change row and the population is unchanged.
    • The unit test's FakeEngine now returns row copies and learns $in. Handing out live stored objects hid the stale-copy defect from every pin.
    • Commit trailers are the model-free pair AGENTS.md requires.

    Recorded: the card's draft compared an env-wide overlay against the bulk copy taken before the package entry's write. It would have reverted every env-wide overlay on each registry-path boot. The dev found it, fixed it (the copy is dropped after a write) and pinned it (A11).

    Findings (all go to Acceptance notes; carrier: none)

    • Case-insensitive collation, unmeasured. On a case-insensitive MySQL collation, two organizations holding one slot in different letter case could resolve to different rows on the two paths. A case-variant miss still falls back to the lookup, which is the safe direction.
    • Log wording. The live doors log "materialized" after a skip, as they already did after an admin or customized skip.
    • A duplicated slot. On the registry path, a slot listed twice (a package entry, then its overlay) still pays both writes per boot, as before. This is not a regression; the plugin's own boot reads the effective list.

    Breaker readings (#21999): none. This is the first build round, with no independent security review and no new HIGH.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T08:58Z

    • PR fix(plugin-email): the boot sweep reads stored templates in bulk and no longer rewrites an unchanged row #22065 merged through the queue as 56bf27affb, read on origin/main. This card closed completed by Fixes #22062, and pm:dispatched is stripped in this act.
    • Closing-keyword check: the PR body carries one closing keyword, and only this card closed at the merge.
    • What landed:
    • Measured on ObjectQL over SqlDriver: a steady boot over 79 unchanged templates sent 316 statements and now sends 1 read; over 450 templates, 1,800 statements become 3 reads and no write. Review: 6033573187.
    • For the repo:cloud seat: the framework half is on main at 56bf27affb. The hosted half of "Done when", 1ac85ba2's first build inside the 600 s timeout on a pin that carries this fix, is verified on objectstack-ai/cloud#2637 if the staging reading attributes the time to this step.

    Generated by Claude Code

  7. added 2 commits that reference this issue on Oct 7, 2026
    56bf27a
    8c5aa50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratebugSomething isn't workingdomain:servicespriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions