You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2026/09/16 08:18:17 UTC

(skywalking-banyandb) branch feat/measure-tag-aggregation created (now 5b0e85401)

This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a change to branch feat/measure-tag-aggregation
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


      at 5b0e85401 docs(design): add tag aggregation and time bucketing design for 0.12.0

This branch includes the following new commits:

     new 9832c7199 chore(deps): close canopy and mcp Dependabot CVEs
     new 5b0e85401 docs(design): add tag aggregation and time bucketing design for 0.12.0

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(skywalking-banyandb) 01/02: chore(deps): close canopy and mcp Dependabot CVEs

Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a commit to branch feat/measure-tag-aggregation
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit 9832c71996db09743f7c749fa8fde30bc28144c0
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Sat Sep 5 03:37:04 2026 +0000

    chore(deps): close canopy and mcp Dependabot CVEs
    
    Bump fast-uri/fastify in canopy and fast-uri/qs in mcp, then regenerate license files.
    
    Co-authored-by: Cursor <cu...@cursor.com>
---
 CHANGES.md                 |   4 +
 canopy/package-lock.json   | 988 +++------------------------------------------
 canopy/package.json        |   2 +
 canopy/server/package.json |   2 +-
 dist/LICENSE               |   4 +-
 mcp/LICENSE                |   4 +-
 mcp/package-lock.json      |  12 +-
 mcp/package.json           |   3 +-
 8 files changed, 67 insertions(+), 952 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index a8489bffa..eed0ccb5c 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -9,4 +9,8 @@ Release Notes.
 - Add the [native inverted-index replacement design package](docs/design/archive/0.12.0/native-inverted-index/README.md), including the implementation specification, ICE walkthrough, research plan, and visual report.
 - Add mandatory size and TDD-feasibility audits to the BanyanDB GitHub issue skill.
 
+### Chores
+
+- Bump canopy and mcp npm dependencies to clear Dependabot CVEs (fast-uri, fastify, qs).
+
 ## 0.11.0
diff --git a/canopy/package-lock.json b/canopy/package-lock.json
index 473c6e804..a4913a23f 100644
--- a/canopy/package-lock.json
+++ b/canopy/package-lock.json
@@ -265,443 +265,18 @@
         "node": ">=18"
       }
     },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
-      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
-      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
-      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
-      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
-      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
-      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
-      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
-      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
-      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
-      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
-      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
-      "cpu": [
-        "loong64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
-      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
-      "cpu": [
-        "mips64el"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
-      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
-      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
-      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
-      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
-      "cpu": [
-        "x64"
-      ],
-      "devOptional": true,
-      "license": "MIT",
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
-      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
-      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
-      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
-      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
-      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
-      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
-      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
+    "node_modules/@esbuild/linux-x64": {
       "version": "0.28.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
-      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
       "cpu": [
         "x64"
       ],
+      "devOptional": true,
       "license": "MIT",
-      "optional": true,
       "os": [
-        "win32"
+        "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -891,9 +466,9 @@
       }
     },
     "node_modules/@fastify/ajv-compiler/node_modules/ajv/node_modules/fast-uri": {
-      "version": "3.1.5",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
-      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+      "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
       "funding": [
         {
           "type": "github",
@@ -1269,266 +844,45 @@
       "version": "0.144.0",
       "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
       "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/Boshen"
-      }
-    },
-    "node_modules/@pinojs/redact": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
-      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
-      "license": "MIT"
-    },
-    "node_modules/@playwright/test": {
-      "version": "1.62.1",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
-      "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "playwright": "1.62.1"
-      },
-      "bin": {
-        "playwright": "cli.js"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
-      "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
-      "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
-      "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
-      "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
-      "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
-      "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
-      "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
-      "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
-      "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
-      "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
-      "cpu": [
-        "x64"
-      ],
-      "devOptional": true,
-      "license": "MIT",
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
-      "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
-    "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
-      "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
       }
     },
-    "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
-      "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
+    "node_modules/@pinojs/redact": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+      "license": "MIT"
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.62.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+      "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.62.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
       "engines": {
-        "node": "^20.19.0 || >=22.12.0"
+        "node": ">=20"
       }
     },
-    "node_modules/@rolldown/binding-win32-x64-msvc": {
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
       "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
-      "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
+      "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
       "cpu": [
         "x64"
       ],
+      "devOptional": true,
       "license": "MIT",
-      "optional": true,
       "os": [
-        "win32"
+        "linux"
       ],
-      "peer": true,
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
       }
@@ -2616,9 +1970,9 @@
       }
     },
     "node_modules/ajv-formats/node_modules/fast-uri": {
-      "version": "3.1.5",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
-      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+      "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
       "funding": [
         {
           "type": "github",
@@ -4105,9 +3459,9 @@
       }
     },
     "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": {
-      "version": "3.1.5",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
-      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+      "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
       "funding": [
         {
           "type": "github",
@@ -4142,9 +3496,9 @@
       }
     },
     "node_modules/fast-uri": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz",
-      "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
+      "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
       "funding": [
         {
           "type": "github",
@@ -4158,9 +3512,9 @@
       "license": "BSD-3-Clause"
     },
     "node_modules/fastify": {
-      "version": "5.12.0",
-      "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.0.tgz",
-      "integrity": "sha512-A3RNEaDIHWaxFW8n8rNJaW1wQ+XAXuoU71llfUQJjuh5WaYLmKfRhhanaJBOx8m2EBPQkR6sDBovYdHNe9F6rA==",
+      "version": "5.12.3",
+      "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz",
+      "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==",
       "funding": [
         {
           "type": "github",
@@ -5417,237 +4771,6 @@
         "lightningcss-win32-x64-msvc": "1.33.0"
       }
     },
-    "node_modules/lightningcss-android-arm64": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
-      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-darwin-arm64": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
-      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-darwin-x64": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
-      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-freebsd-x64": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
-      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm-gnueabihf": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
-      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm64-gnu": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
-      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm64-musl": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
-      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-x64-gnu": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
-      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-x64-musl": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
-      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-win32-arm64-msvc": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
-      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-win32-x64-msvc": {
-      "version": "1.33.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
-      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7771,21 +6894,6 @@
         }
       }
     },
-    "node_modules/vite/node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
     "node_modules/vitest": {
       "version": "4.1.10",
       "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
@@ -8168,7 +7276,7 @@
         "bcryptjs": "^3.0.2",
         "canopy-shared": "*",
         "eslint-plugin-react": "^7.37.5",
-        "fastify": "^5.4.0",
+        "fastify": "^5.12.1",
         "js-yaml": "^4.1.0",
         "undici": "^7.10.0"
       },
diff --git a/canopy/package.json b/canopy/package.json
index cbccbc551..f3d837b30 100644
--- a/canopy/package.json
+++ b/canopy/package.json
@@ -35,6 +35,8 @@
   },
   "overrides": {
     "brace-expansion": "^5.0.9",
+    "fast-uri@^3": "3.1.7",
+    "fast-uri@^4": "4.1.4",
     "minimatch": "^10.0.3",
     "react": "^19.2.7",
     "react-dom": "^19.2.7"
diff --git a/canopy/server/package.json b/canopy/server/package.json
index ec5ead7d3..410cbada2 100644
--- a/canopy/server/package.json
+++ b/canopy/server/package.json
@@ -22,7 +22,7 @@
     "bcryptjs": "^3.0.2",
     "canopy-shared": "*",
     "eslint-plugin-react": "^7.37.5",
-    "fastify": "^5.4.0",
+    "fastify": "^5.12.1",
     "js-yaml": "^4.1.0",
     "undici": "^7.10.0"
   },
diff --git a/dist/LICENSE b/dist/LICENSE
index 358b63f62..5065ad534 100644
--- a/dist/LICENSE
+++ b/dist/LICENSE
@@ -642,8 +642,8 @@ BSD-2-Clause licenses
 BSD-3-Clause licenses
 ========================================================================
 
-    fast-uri 4.1.2 BSD-3-Clause
-    qs 6.15.3 BSD-3-Clause
+    fast-uri 4.1.4 BSD-3-Clause
+    qs 6.16.0 BSD-3-Clause
 
 ========================================================================
 ISC licenses
diff --git a/mcp/LICENSE b/mcp/LICENSE
index 7e13ba4bb..8eb109201 100644
--- a/mcp/LICENSE
+++ b/mcp/LICENSE
@@ -35,8 +35,8 @@ BSD-2-Clause licenses
 BSD-3-Clause licenses
 ========================================================================
 
-    fast-uri 4.1.2 BSD-3-Clause
-    qs 6.15.3 BSD-3-Clause
+    fast-uri 4.1.4 BSD-3-Clause
+    qs 6.16.0 BSD-3-Clause
 
 ========================================================================
 ISC licenses
diff --git a/mcp/package-lock.json b/mcp/package-lock.json
index 3a1f21718..0df7a3e02 100644
--- a/mcp/package-lock.json
+++ b/mcp/package-lock.json
@@ -2284,9 +2284,9 @@
       "license": "MIT"
     },
     "node_modules/fast-uri": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz",
-      "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
+      "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
       "funding": [
         {
           "type": "github",
@@ -3132,9 +3132,9 @@
       }
     },
     "node_modules/qs": {
-      "version": "6.15.3",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
-      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "version": "6.16.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+      "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "es-define-property": "^1.0.1",
diff --git a/mcp/package.json b/mcp/package.json
index 193e416fd..822e8a041 100644
--- a/mcp/package.json
+++ b/mcp/package.json
@@ -49,6 +49,7 @@
   },
   "overrides": {
     "@hono/node-server": "^2.1.0",
-    "fast-uri": "^4.1.2"
+    "fast-uri": "^4.1.4",
+    "qs": "^6.16.0"
   }
 }


(skywalking-banyandb) 02/02: docs(design): add tag aggregation and time bucketing design for 0.12.0

Posted by ha...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a commit to branch feat/measure-tag-aggregation
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit 5b0e85401b13196cf418d26f519fbb65e8e4789a
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Wed Sep 16 08:18:06 2026 +0000

    docs(design): add tag aggregation and time bucketing design for 0.12.0
    
    Adds a design (not implementation) for extending the measure query
    engine's existing Map/Reduce aggregation to:
    
    - aggregate over tags in addition to fields, including COUNT_DISTINCT
    - GROUP BY a time bucket (DATE_BIN-equivalent, epoch-anchored,
      defaulting to the measure's own interval) alongside tag groups
    
    Key design decisions, covered in the doc:
    
    - COUNT_DISTINCT is pushed down as a distinct-tuple partial (no new
      wire type), decomposable when the effective routing key (sharding_key
      if set, else entity) is a subset of the grouped tags plus the
      distinct target; this condition is sufficient but not necessary, and
      is a modelling instruction rather than a discovered constraint.
    - Multi-stage COUNT_DISTINCT is rejected outright, bucketed or not:
      bucket width is not proof a query resolves to one stage, since stage
      selection is by physical segment overlap, not a query-time window.
    - Time-bucket grouping streams node-locally on the part-scan path
      (bucket n leaves the operator while n+1 fills), matching measure
      results' default timestamp-ascending order; index-mode measures use
      the non-streaming map instead, since the index path returns before
      ordering is established.
    - Entity and indexed tags never reach the data-file projection, so a
      tag-only group-by/aggregation over such tags reads no field or tag
      family files — metadata and the series index resolve it.
    
    Reviewed by an independent pass that surfaced six findings, all
    applied: the routing-key correction and the multi-stage rejection are
    substantive (both close gaps that would have produced silently wrong
    COUNT_DISTINCT results); the remainder tighten wording (index-mode
    streaming, the local-scan range-containment guard, tag-family
    qualification, and narrowing the streaming claim to node-local).
    
    Restructures docs/design/README.md so the current milestone (0.12.0)
    lives at the top level rather than under archive/, with only shipped
    milestones (0.10.0, 0.11.0) archived; moves the native-inverted-index
    package accordingly and updates its CHANGES.md link.
    
    Status: design only, no implementation in this change.
---
 CHANGES.md                                         |    3 +-
 docs/design/{archive => }/0.12.0/README.md         |    1 +
 .../0.12.0/native-inverted-index/README.md         |    0
 .../native-inverted-index/delivery-review.html     |    0
 .../ice-segment-animated-walkthrough.html          |    0
 .../0.12.0/native-inverted-index/index.html        |    0
 .../native-inverted-index-spec-r0.1-monolith.html  |    0
 .../native-inverted-index/query-lifecycle.html     |    0
 .../0.12.0/native-inverted-index/research-plan.md  |    2 +-
 .../native-inverted-index/research-report.html     |    0
 .../native-inverted-index/research-report.md       |    0
 .../native-inverted-index/safety-verification.html |    0
 .../0.12.0/native-inverted-index/spec.css          |    0
 .../0.12.0/native-inverted-index/spec.js           |    0
 .../0.12.0/native-inverted-index/write-format.html |    0
 docs/design/0.12.0/tag-aggregation/README.md       | 1403 ++++++++++++++++++++
 docs/design/README.md                              |   16 +-
 docs/design/archive/README.md                      |    8 +-
 18 files changed, 1426 insertions(+), 7 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index eed0ccb5c..fa3b7a672 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -6,7 +6,8 @@ Release Notes.
 
 ### Document
 
-- Add the [native inverted-index replacement design package](docs/design/archive/0.12.0/native-inverted-index/README.md), including the implementation specification, ICE walkthrough, research plan, and visual report.
+- Add the [native inverted-index replacement design package](docs/design/0.12.0/native-inverted-index/README.md), including the implementation specification, ICE walkthrough, research plan, and visual report.
+- Add the [tag aggregation and time bucketing design](docs/design/0.12.0/tag-aggregation/README.md) for the measure query engine, covering aggregation over tags, `COUNT_DISTINCT`, and `GROUP BY` time buckets.
 - Add mandatory size and TDD-feasibility audits to the BanyanDB GitHub issue skill.
 
 ### Chores
diff --git a/docs/design/archive/0.12.0/README.md b/docs/design/0.12.0/README.md
similarity index 51%
rename from docs/design/archive/0.12.0/README.md
rename to docs/design/0.12.0/README.md
index 194b57adb..f391da7f7 100644
--- a/docs/design/archive/0.12.0/README.md
+++ b/docs/design/0.12.0/README.md
@@ -1,3 +1,4 @@
 # 0.12.0 Design Documents
 
 - [Native inverted-index replacement](native-inverted-index/README.md)
+- [Tag aggregation and time bucketing in the measure query engine](tag-aggregation/README.md)
diff --git a/docs/design/archive/0.12.0/native-inverted-index/README.md b/docs/design/0.12.0/native-inverted-index/README.md
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/README.md
rename to docs/design/0.12.0/native-inverted-index/README.md
diff --git a/docs/design/archive/0.12.0/native-inverted-index/delivery-review.html b/docs/design/0.12.0/native-inverted-index/delivery-review.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/delivery-review.html
rename to docs/design/0.12.0/native-inverted-index/delivery-review.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/ice-segment-animated-walkthrough.html b/docs/design/0.12.0/native-inverted-index/ice-segment-animated-walkthrough.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/ice-segment-animated-walkthrough.html
rename to docs/design/0.12.0/native-inverted-index/ice-segment-animated-walkthrough.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/index.html b/docs/design/0.12.0/native-inverted-index/index.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/index.html
rename to docs/design/0.12.0/native-inverted-index/index.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/native-inverted-index-spec-r0.1-monolith.html b/docs/design/0.12.0/native-inverted-index/native-inverted-index-spec-r0.1-monolith.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/native-inverted-index-spec-r0.1-monolith.html
rename to docs/design/0.12.0/native-inverted-index/native-inverted-index-spec-r0.1-monolith.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/query-lifecycle.html b/docs/design/0.12.0/native-inverted-index/query-lifecycle.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/query-lifecycle.html
rename to docs/design/0.12.0/native-inverted-index/query-lifecycle.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/research-plan.md b/docs/design/0.12.0/native-inverted-index/research-plan.md
similarity index 99%
rename from docs/design/archive/0.12.0/native-inverted-index/research-plan.md
rename to docs/design/0.12.0/native-inverted-index/research-plan.md
index f74963ee0..41c7a3443 100644
--- a/docs/design/archive/0.12.0/native-inverted-index/research-plan.md
+++ b/docs/design/0.12.0/native-inverted-index/research-plan.md
@@ -440,7 +440,7 @@ The research suite must cover at least the following rows across create, persist
 Names may change during review, but ownership should remain clear:
 
 ```text
-docs/design/archive/0.12.0/native-inverted-index/
+docs/design/0.12.0/native-inverted-index/
   research-baseline.md
   provenance.md
   native-index-requirements.md
diff --git a/docs/design/archive/0.12.0/native-inverted-index/research-report.html b/docs/design/0.12.0/native-inverted-index/research-report.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/research-report.html
rename to docs/design/0.12.0/native-inverted-index/research-report.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/research-report.md b/docs/design/0.12.0/native-inverted-index/research-report.md
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/research-report.md
rename to docs/design/0.12.0/native-inverted-index/research-report.md
diff --git a/docs/design/archive/0.12.0/native-inverted-index/safety-verification.html b/docs/design/0.12.0/native-inverted-index/safety-verification.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/safety-verification.html
rename to docs/design/0.12.0/native-inverted-index/safety-verification.html
diff --git a/docs/design/archive/0.12.0/native-inverted-index/spec.css b/docs/design/0.12.0/native-inverted-index/spec.css
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/spec.css
rename to docs/design/0.12.0/native-inverted-index/spec.css
diff --git a/docs/design/archive/0.12.0/native-inverted-index/spec.js b/docs/design/0.12.0/native-inverted-index/spec.js
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/spec.js
rename to docs/design/0.12.0/native-inverted-index/spec.js
diff --git a/docs/design/archive/0.12.0/native-inverted-index/write-format.html b/docs/design/0.12.0/native-inverted-index/write-format.html
similarity index 100%
rename from docs/design/archive/0.12.0/native-inverted-index/write-format.html
rename to docs/design/0.12.0/native-inverted-index/write-format.html
diff --git a/docs/design/0.12.0/tag-aggregation/README.md b/docs/design/0.12.0/tag-aggregation/README.md
new file mode 100644
index 000000000..8f854fa2e
--- /dev/null
+++ b/docs/design/0.12.0/tag-aggregation/README.md
@@ -0,0 +1,1403 @@
+# Tag Aggregation and Time Bucketing in the Measure Query Engine
+
+Status: **design** — not implemented.
+
+This document specifies three additions to BanyanDB's existing measure query engine:
+
+1. aggregation over **tags**, not just fields;
+2. an exact **`COUNT_DISTINCT`** function;
+3. **time-bucket grouping** — `GROUP BY <bucket>, <tags>` producing a series, with the bucket
+   width taken from the request or, failing that, from the measure's `interval`.
+
+They are separable features that share one API surface and one operator, and a dashboard needs
+all three: (1) and (2) answer "how many distinct things", (3) answers "over time".
+
+(3) is driven by a separate requirement — converting analytical `GROUP BY <bucket>, <tags>` SQL
+onto BanyanDB (§1.1) — not by the cardinality work. That conversion also needs two things this
+document scopes out, multiple aggregates per request and result aliases; §3 records the gap.
+
+It is a companion to the [native inverted-index design](../native-inverted-index/README.md): the
+scoping work for that package asked whether the native index should grow its own local
+aggregation, and concluded it should not. Aggregation stays in the query engine, which already
+reduces locally on data nodes and combines at the liaison; this design extends that machinery
+rather than duplicating it.
+
+---
+
+## 1. Motivation
+
+Dashboards built on SkyWalking-shaped measures routinely need cardinality answers where the
+interesting dimension is a **tag**, not a field. Take a per-api-key request-rate measure: the
+user is the `service_id` tag, the api-key is the `entity_id` tag, and the only field is a scalar
+request count. Three questions follow immediately.
+
+| Case | Question | SQL shape |
+|---|---|---|
+| **Scalar** | "how many distinct api-keys did one user use last week?" | `COUNT(DISTINCT entity_id) WHERE service_id = …` over a multi-bucket window |
+| **Ranked** | "show every user, ranked by key count, page 3" | `GROUP BY service_id`, `ORDER BY COUNT(DISTINCT entity_id) DESC`, `LIMIT`/`OFFSET` |
+| **Series** | "plot distinct api-keys per 5 minutes for the last hour" | `GROUP BY <time bucket>, service_id ORDER BY <time bucket> ASC` |
+
+The ranked case is what a dashboard row needs; the series case is what a dashboard *chart* needs.
+Every realistic dashboard window spans many buckets, so the scalar case is never asked over a
+single bucket either.
+
+The series case is the shape analytical SQL expresses with a bucketing function — PostgreSQL and
+DataFusion spell it `DATE_BIN(INTERVAL <width>, time, <origin>)`, others `time_bucket` or
+`GROUP BY FLOOR(time/width)`. The semantics this design adopts are that function's, with the
+origin fixed at the Unix epoch: each row is assigned to `floor(timestamp / width) * width`, and
+rows sharing a bucket and a tag key form one group.
+
+None of the three is expressible today.
+
+- `measurev1.QueryRequest.Aggregation` names only a **field**. `plan/analyzer.go:301
+  validateAggField` walks `measureSchema.GetFields()` and rejects anything else.
+- `modelv1.AggregationFunction` is `{MEAN, MAX, MIN, COUNT, SUM}` — there is no `COUNT_DISTINCT`.
+- **There is no time bucketing anywhere in the measure query API.** `QueryRequest.GroupBy` is
+  `{tag_projection, field_name}` — tags only; the timestamp is not addressable as a group key,
+  and no interval/step/bin concept exists in `query.proto`. Worse, aggregation deliberately
+  *discards* time: `buildAggOutputLayout` emits `[shard_id?] + tags + agg columns` with no
+  `RoleTimestamp`, and `plan/build_test.go:221` pins it — *"aggregation output must drop
+  timestamp (D2)"*. The row path agrees (`measure_plan_aggregation.go:286,342` build
+  `DataPoint{TagFamilies: …}` and never set `Timestamp`). So `agg` collapses the entire
+  `time_range` into one row per tag group, always.
+
+The workarounds all fail:
+
+1. **`COUNT(*)` over one bucket.** This leans on storage usually holding one row per
+   `(entity, bucket)` — and `Measure.interval` is a **declared cadence, not an enforced
+   invariant**. Writers may emit off-cadence: on a 1-minute measure, timestamps of `09:01:00`
+   *and* `09:01:34` are both legal and both land in the 09:01 bucket. So the count can be wrong
+   even inside a single bucket, and it is certainly wrong once the window spans two — measured at
+   21 for 3 keys over 7 buckets. No error, just a plausible wrong number. This is the strongest
+   argument for a real `COUNT_DISTINCT`: the shortcut it replaces has no invariant behind it,
+   only a convention writers are free to break.
+2. **`groupBy(entity_id)` and count rows client-side.** Correct at any window, but it ships one
+   row per distinct value to the *client*, and it does not compose with `top` / `offset` /
+   `limit`, so the ranked case cannot be rendered.
+3. **One query per bucket.** Correct at any window — each individual query is the workaround-1
+   shape, exact precisely because its window is one bucket wide — and it is the only workaround
+   that still composes with ranking. The cost is a fan-out: a 1-hour window at 1-minute
+   granularity is 60 round-trips, a day is 1440, and the caller carries the bucket arithmetic,
+   the partial-failure handling and the reassembly.
+
+### 1.1 The driving requirement for time bucketing
+
+The series case is not a speculative extension of the cardinality work. It comes from a separate
+requirement: converting existing analytical queries onto BanyanDB, where the canonical shape is
+
+> group by **a time bucket plus two or more tag dimensions**, select a `COUNT(DISTINCT <tag>)`
+> over a further tag that is **not** carried as an output column, alias the result, and select
+> additional aggregates alongside it in the same row.
+
+Read as a checklist, that shape is the fastest way to see what the design must cover:
+
+| Element of the shape | Requirement | Where |
+|---|---|---|
+| group by a time bucket | time-bucket group key, emitted as the row timestamp | §5.1, §7.2 |
+| group by several tags as well | **multiple** tag group keys alongside the bucket | §5.1 |
+| `COUNT(DISTINCT <tag>)` | `COUNT_DISTINCT` over a tag | §5.1, §7.4 |
+| the distinct target is not an output column | target stripped from the output, not emitted as a first-seen tag | §5.2 |
+| the result is aliased | a result alias | **out of scope** — §3 |
+| further aggregates in the same row | more than one aggregate per request | **out of scope** — §3 |
+
+The last two rows are deliberately out of scope (§3). This design delivers the `COUNT_DISTINCT`
+half of the shape; a caller converting such a query issues the distinct count as its own request
+and names the result column client-side. That is a smaller step than the full conversion, and
+saying so plainly is better than implying the checklist is fully served.
+
+Two further notes on mapping this query onto a measure:
+
+- **All group tags must live in one tag family.** `distributedGroupByTagNames`
+  (`distributed.go:1131`) reads `families[0]` only, so the tag dimensions grouped alongside the
+  bucket must share a family. This is a pre-existing v1 limitation, not one this design adds, but
+  it constrains the schema the conversion targets.
+- **The effective routing key determines whether the distinct pushes down** (§7.4) — the
+  measure's `sharding_key` when set, otherwise its entity. The condition is
+  `routingTags ⊆ groupByTags ∪ {target}`, so a routing key drawn from the grouped tags plus the
+  distinct target pushes down; a routing tag outside that set gets the query rejected. A
+  conversion gets to *choose* its routing key, so this is a modelling instruction rather than a
+  constraint discovered too late.
+- **The query must resolve to a single stage** (§7.4). Multi-stage `COUNT_DISTINCT` is rejected,
+  and an empty `stages` falls back to `DefaultStages` — so this is a constraint on the target
+  group's lifecycle configuration, not just on the query text.
+
+The gap is narrow and the engine already has every primitive needed to close it: grouping over
+tag columns, a Map/Reduce split across data nodes and the liaison, a per-query memory budget,
+top-N/pagination over an aggregate output column, and — for the series case — an
+epoch-anchored bucketing formula already in the tree (`getWindowStart` in
+`pkg/flow/streaming/sliding_window.go:302`, used by the TopN pre-aggregation path).
+
+**Time bucketing and tag aggregation are orthogonal**, and the series case needs both. They would
+be near-interchangeable if a measure held exactly one row per `(entity, interval)` — then
+counting rows in a bucket would count distinct entities. It does not: the interval is a declared
+cadence, so two rows for one entity can share a bucket. `COUNT` grouped by bucket therefore
+answers "how many data points", `COUNT_DISTINCT` answers "how many distinct things", and they
+diverge whenever a writer emits off-cadence or a query bucket is coarser than the interval.
+Shipping either feature alone leaves half the dashboard unservable.
+
+---
+
+## 2. Requirements
+
+- `agg` may target a **tag** as well as a field.
+- A new `COUNT_DISTINCT` function, **exact** (not sketch-based).
+- `group_by` may key on a **time bucket**, producing one row per `(bucket, tag group)` and a
+  timestamp on each result row. The width comes from the request or from the measure's
+  `interval` — see §5.3.
+- Bucket grouping must **stream node-locally**: on the part path, live state is one bucket rather
+  than the whole window, and a node emits a bucket as it closes instead of withholding until its
+  scan ends (§7.2). This is a requirement rather than an optimisation — it is what makes a
+  day-long query at 1-minute buckets viable at all. It is explicitly **not** an end-to-end
+  pipelining promise: the liaison collects node frames before reducing them, so the *query*
+  remains request/response even where the *operator* streams. Index mode does not stream at all
+  (§7.2).
+- All of the above compose with each other and with the existing `top`, `offset` and `limit`, in
+  standalone **and** distributed deployments.
+- A high-cardinality target must fail loudly against the per-query budget, never OOM a node.
+- `COUNT_DISTINCT` **pushes down to the data nodes like every other function**, with a scalar
+  partial. A request that cannot be pushed down is **rejected at analyze time** — there is no
+  second, costlier execution path (§7.4).
+- **`index_mode` measures are in scope** for both grouping and aggregation, on the same terms as
+  part-backed measures (§7.4, §7.7).
+- Reachable from BydbQL, not only from clients that hand-build a `QueryRequest`.
+- Default behaviour is unchanged: a request with no `time_bucket` still collapses the whole
+  `time_range`, exactly as today.
+
+## 3. Non-goals
+
+- **Approximate distinct (HyperLogLog).** Constant wire cost is attractive, but it needs a new
+  partial wire type and an API contract about error bounds. The cardinality of the motivating
+  case (api-keys per user) does not justify either. Revisit only if a deployment actually hits
+  the budget.
+
+> **Decided:** the two entries below are out of scope. `COUNT_DISTINCT` is the piece of §1.1 this
+> design delivers; the remaining elements of that query shape are deferred deliberately, not left
+> open. A caller converting such a query issues the distinct count as its own request and names
+> the result column client-side.
+
+- **Multiple aggregates in one request.** `QueryRequest.agg` stays singular and BydbQL keeps
+  erroring on a second aggregate in `SELECT`. The operator is already plural — `AggSpec` is a
+  slice and `BatchAggregation` loops over `a.aggs` — so if this is ever picked up the work is API
+  and planning rather than execution, and it can be added as `repeated Aggregation` under a new
+  field number without breaking the singular form.
+- **Result aliases (`AS`).** No BydbQL `AS` production and no `Aggregation.output_name`. The
+  result column keeps the naming rule in §5.2: it is named after the aggregation target.
+- **Row-path parity.** See §8.
+- **`COUNT_DISTINCT` in `TopNAggregation` pre-aggregation rules.** Rejected, not implemented.
+- **Per-bucket `top`.** With `time_bucket` set, `top`/`offset`/`limit` stay **global** over the
+  whole result set — one heap, exactly today's semantics. "Top 10 devices *in each* bucket" is
+  the TopN RPC's job and already exists there: `TopNResponse.lists` is "a series topN lists
+  ranked by timestamp". Redefining `top` per bucket would fork that semantics across two APIs.
+  See §10 for the consequence this has for `limit`.
+- **Bucket gap-filling.** Buckets with no data produce no row. Zero-filling a sparse series is a
+  presentation concern and needs the client to know the expected bucket set anyway.
+- **Arbitrary bucket origins.** Buckets are anchored at the Unix epoch, matching the `DATE_BIN`
+  call above with `TIMESTAMP '1970-01-01 00:00:00'`. A configurable origin is not in scope.
+
+---
+
+## 4. Background: how measure aggregation works today
+
+The vectorized engine is the default (`vectorized/measure/config.go DefaultConfig` → `Enabled:
+true`); the row engine in `pkg/query/logical/measure` is explicitly marked *"Do not extend with
+new features"* (`measure_analyzer.go:62-68`).
+
+**Standalone.** `plan.Analyze` builds a `GroupByAgg` node which `BuildOperators`
+(`vectorized/measure/plan.go:61`) turns into one `BatchAggregation`. Group keys are encoded by
+`appendKeyComponent` (`groupby.go:287`) — a shared helper that handles int64, float64, string,
+bytes, `*modelv1.TagValue` and `*modelv1.FieldValue`. Each group holds one `aggSlot` per
+aggregate, backed by `pkg/query/aggregation`.
+
+**Distributed.** The liaison sets `InternalQueryRequest.AggReturnPartial`
+(`plan/distributed.go:474`), which the data node turns into `AggModeMap`
+(`plan/dispatch.go:120-123`). The node emits typed-column *partials* — a `Partial{Value, Count}`
+per group — serialized by `vectorized/measure/frame`. The liaison runs `AggModeReduce`
+(`reduce.go:107 ReducePartialBatches`), deduplicating replicas on `(shard_id, group_key)` before
+combining.
+
+Two structural facts this design leans on:
+
+- **Tag columns cross the wire fine.** The frame codec carries string and bytes columns with
+  their family name intact (`frame/encode.go:65-69`, `decode.go:105-107`). The raw-GroupBy
+  distributed path (`distributed.go:913 applyBatchGroupByFirstToRows`) already ships tag columns
+  from data nodes and dedupes them liaison-side. Only array columns have no wire type
+  (`frame/frame.go:185-202` → `ErrUnsupportedColumnType`).
+- **A tag is only a *native typed* column when something keys off it.** `BuildBatchSchema`
+  (`integration.go:60`) consults `buildNativeTagSet` (`:139`), which today contains only GroupBy
+  keys. Every other tag stays a `ColumnTypeTagValue` passthrough. This matters because
+  `aggregation.go:464 fold` hard-casts to `TypedColumn[int64]`/`[float64]` with no fallback.
+
+---
+
+## 5. API
+
+### 5.1 Proto
+
+One enum value in `api/proto/banyandb/model/v1/common.proto`:
+
+```proto
+enum AggregationFunction {
+  …
+  AGGREGATION_FUNCTION_SUM = 5;
+  // COUNT_DISTINCT counts the distinct non-null values of the target.
+  // It is exact and bounded by the per-query memory budget.
+  AGGREGATION_FUNCTION_COUNT_DISTINCT = 6;
+}
+```
+
+One field on `QueryRequest.Aggregation` in `api/proto/banyandb/measure/v1/query.proto`:
+
+```proto
+message Aggregation {
+  model.v1.AggregationFunction function = 1;
+  // field_name must be one of files indicated by the field_projection
+  string field_name = 2;
+  // tag_name aggregates over a tag instead of a field. Exactly one of
+  // field_name and tag_name must be set.
+  string tag_name = 3;
+}
+```
+
+One field on `QueryRequest.GroupBy`, in the same file:
+
+```proto
+message GroupBy {
+  // tag_projection must be a subset of the tag_projection of QueryRequest
+  model.v1.TagProjection tag_projection = 1;
+  // field_name must be one of fields indicated by field_projection
+  string field_name = 2;
+  message TimeBucket {
+    // width is a duration string using the same units as Measure.interval
+    // ("ns", "us", "ms", "s", "m", "h", "d"). Empty means "use the measure's
+    // interval"; if the measure has no interval either, the request is
+    // rejected.
+    string width = 1;
+  }
+  // time_bucket adds the data point's timestamp, floored to a bucket boundary,
+  // as the leading group key, and re-emits it as the result row's timestamp.
+  // Buckets are anchored at the Unix epoch: bucket_start = ts - ts % width,
+  // matching DATE_BIN with a Unix-epoch origin. Unset means no time bucketing
+  // — the whole time_range collapses to one row per tag group, as today.
+  TimeBucket time_bucket = 3;
+}
+```
+
+All additions are wire-compatible; existing clients are unaffected, and an unset `time_bucket`
+preserves today's behaviour exactly.
+
+**The aggregation target is qualified by family.** An earlier draft used a bare `tag_name`,
+reasoning that the engine keys tag specs by bare name
+(`pkg/query/logical/schema.go:92-99`) and so names must be unique. That is an implementation
+assumption, not a schema invariant — and the two are not the same thing. Tag-family validation
+(`api/validate/validate.go:329-347`) does not reject a name repeated across families;
+`pkg/pb/v1/metadata.go:27-35` makes the same unenforced assumption; and
+`pkg/query/vectorized/schema_test.go:81-94` explicitly exercises identical names in different
+families. `RegisterTag` silently overwrites one family's spec with another's.
+
+So a bare name can be ambiguous on a valid schema. Two ways out were available: retrofit a global
+uniqueness rule, or qualify at the new seam. **Qualifying is the correct one** — a uniqueness
+rule would invalidate existing schemas that are legal today, to serve a feature they do not use.
+`GroupBy` already qualifies by family; the aggregation target now matches it.
+
+### 5.2 Result naming
+
+The result column **inherits the target's name**, extending the existing field rule
+(`plan.go:109-116`, pinned by `plan_test.go
+TestBuildOperators_AggOutputName_InheritsInputFieldName`). So
+`COUNT_DISTINCT(entity_id) GROUP BY service_id` returns tag `service_id` plus field `entity_id`,
+and the ranked case orders with `top.field_name = "entity_id"` — no new API surface for ordering.
+
+When the analyzer injects the target tag into the tag projection itself (the caller did not ask
+for it), that tag is **stripped from the output**. Otherwise every result row would also carry a
+meaningless first-seen copy of the value being counted. If the caller *does* project the tag
+explicitly, they get both a tag and a field of that name; the two live in separate namespaces
+(`BatchSchema.tagByPath`, keyed by `(family, name)`, vs `fieldByName`) so nothing collides.
+
+The bucket has no such naming question: it is carried on `DataPoint.timestamp`, a field the
+message already has, set to the **bucket start**. There is no synthetic `time_bucket` tag or
+field — the wire shape of a bucketed result is the wire shape of any other data point.
+
+### 5.3 Resolving the bucket width
+
+The width comes from the request, or failing that from the measure. Either source is enough;
+only the absence of both is an error.
+
+| `time_bucket` | `Measure.interval` | Outcome |
+|---|---|---|
+| unset | — | No bucketing. Today's behaviour, unchanged. |
+| set, `width` given | — | Bucket width = `width`. |
+| set, `width` empty | set | Bucket width = the measure's interval. |
+| set, `width` empty | empty | **Reject** — *"time_bucket needs a width: measure %q declares no interval"*. |
+| set, `width` unparseable / zero / negative | — | Reject. |
+
+Both sources are parsed with `timestamp.ParseDuration` (`pkg/timestamp/duration.go:31`), the same
+parser the write path uses on the schema field (`banyand/measure/measure.go:118`), so the two
+agree on units by construction.
+
+**There is deliberately no "must be a multiple of the interval" rule.** An earlier draft required
+one, on the reasoning that a narrower or non-aligned bucket would partition the data
+arbitrarily. That reasoning assumed `Measure.interval` describes where data points actually land
+— and it does not. The field declares a *cadence writers are asked to follow*, not an invariant
+storage enforces: on a 1-minute measure, timestamps of `09:01:00` and `09:01:34` are both legal.
+
+Once points may fall anywhere, every width is equally meaningful — binning arbitrary timestamps
+is exactly what `DATE_BIN` does — and a multiple-of rule would reject useful queries to protect
+an alignment that was never guaranteed. What survives from that reasoning is a cost observation
+rather than a constraint: bucket count is `time_range / width`, so a very small width against a
+long window produces many groups. That is bounded by the memory budget (§7.6) and by `limit`
+(§10), which is where cost belongs.
+
+The same correction removes an assumption that ran deeper than this section — see §1 on why
+`COUNT(*)` is unreliable even within a single bucket, and §11 on the test that assumption would
+have produced.
+
+### 5.4 BydbQL
+
+```sql
+SELECT service_id, COUNT(DISTINCT entity_id) FROM MEASURE … GROUP BY service_id
+SELECT host, COUNT(host) FROM MEASURE … GROUP BY TIME_BUCKET('5m'), host
+```
+
+`DISTINCT` is accepted inside `COUNT` only; `SUM(DISTINCT …)` is a parse error. The aggregate
+column resolves against tags as well as fields — `transformer.go:846 convertAggregation`
+currently hard-errors when the column is not in `allFields`.
+
+`TIME_BUCKET(<duration>)` is a pseudo-column accepted only in `GROUP BY`, at most once, and it
+maps to `GroupBy.time_bucket`. The argument may be omitted — `TIME_BUCKET()` requests bucketing
+at the measure's own interval, the `width`-empty case in §5.3. It is deliberately not selectable
+in the projection: the bucket arrives on the data point's timestamp, not as a column. Results are
+ordered by bucket ascending (§7.5), so an explicit `ORDER BY` on the bucket is unnecessary.
+
+---
+
+## 6. Semantics
+
+Validated at analyze time; anything outside the matrix is a hard error naming the target and its
+type.
+
+| Target type | SUM / MIN / MAX / MEAN | COUNT | COUNT_DISTINCT |
+|---|---|---|---|
+| Field INT / FLOAT | ✅ today | ✅ today | ✅ new |
+| Tag `TAG_TYPE_INT` | ✅ new | ✅ new | ✅ new |
+| Tag `TAG_TYPE_STRING` | ❌ reject | ✅ new | ✅ new |
+| Tag `TAG_TYPE_DATA_BINARY` | ❌ reject | ✅ new | ✅ new |
+| Tag `*_ARRAY`, `TAG_TYPE_TIMESTAMP` | ❌ reject | ❌ reject | ❌ reject |
+
+- Null values are excluded, matching the current field behaviour: they do not increment `COUNT`
+  and do not enter the distinct set.
+- `MEAN` over an INT tag yields float64, matching the existing field rule.
+- **Array and timestamp tags must be rejected explicitly, not left to fall through.**
+  `tagTypeToColumnType` (`integration.go:162`) already errors on `TIMESTAMP`, but array tags map
+  to `ColumnTypeInt64Array`/`ColumnTypeStrArray`, and `appendKeyComponent`
+  (`groupby.go:287-303`) *silently returns the buffer unchanged* for those — every row would
+  collapse into one group and produce a plausible wrong count, the exact failure mode this
+  design exists to remove. A `keyComponentSupported(ColumnType) bool` predicate should sit next
+  to `appendKeyComponent` so the analyzer's rejection list and the operator's assumption cannot
+  drift apart.
+
+Time bucketing is orthogonal to this matrix: it adds a group key, not an aggregate, so it
+composes with every cell above. Its own validation rules are in §5.3. Two interactions are worth
+stating explicitly:
+
+- **A bucket key does not change what an aggregate means** — it only narrows the row set each
+  aggregate sees. `COUNT_DISTINCT` per bucket counts values distinct *within* that bucket; a
+  value present in two buckets counts once in each. That is the intended series semantics, and it
+  is deliberately not the same number as the unbucketed total.
+- **`TAG_TYPE_TIMESTAMP` tags remain rejected** as aggregation targets. The bucket key comes from
+  the data point's own timestamp, which is not a tag, so the two never meet.
+
+---
+
+## 7. Execution design
+
+### 7.1 Binding the target to a column
+
+`model.MeasureAgg` gains `TagName`, `TagFamily` and `HideTag` (the last set when the analyzer
+injected the projection). Four existing seams become target-aware:
+
+| Seam | Change |
+|---|---|
+| `plan/analyzer.go:209 translateAgg` | resolve tag **or** field; enforce the §6 matrix |
+| `plan/analyzer.go:264 ensureAggFieldProjected` | mirror `ensureGroupByProjected` (`:226`) and inject the tag, setting `HideTag` |
+| `integration.go:139 buildNativeTagSet` | include the agg tag, so its column is native typed |
+| `plan.go:155 lookupFieldColumnIndex` | add a tag sibling resolving on `(RoleTag, TagFamily, Name)`, like `lookupGroupByKeyIndices` (`:135`) |
+
+The `buildNativeTagSet` change is load-bearing: without it the target column is a
+`ColumnTypeTagValue` passthrough and `fold` panics on its type assertion. This is the same
+native-column contract the analyzer already documents at `analyzer.go:73-79`.
+
+Everything downstream of `AggSpec.InputCol` is index-based, so **SUM/MIN/MAX/MEAN/COUNT over an
+INT tag need no execution changes at all** — the partial is a `RoleField` int64/float64 column
+named after the target, exactly what `bindAggReduceSpecs` (`reduce.go:219`) already binds. Only
+two small operator fixes are needed:
+
+- `aggregation.go:464 fold` — `COUNT` must not read the value for non-numeric columns (it
+  currently parses, then calls `In(v)`). A null-check-only branch makes `COUNT(string_tag)` work.
+- `aggregation.go:252 collectTagIndices` — accept an exclusion index so a `HideTag` target is
+  not emitted as a first-seen tag beside its own result field.
+
+### 7.2 Time-bucket grouping
+
+The implementation is one small map operator plus one conditional in the output layout. The key
+insight is that **the timestamp is already an `int64` column** in the input schema
+(`RoleTimestamp`, produced by `BuildBatchSchema`'s fixed prefix at `integration.go:64-69`), and
+`appendKeyComponent` already encodes `int64` key components. So the bucket does not need a new
+key encoder, a derived column, or a change to `computeKey`:
+
+> **Floor the timestamp column in place, then group by it like any other column.**
+
+A `BatchTimeBucket` map operator sits immediately before the aggregation and rewrites the
+timestamp column to its bucket start:
+
+```go
+// bucketStart mirrors pkg/flow/streaming/sliding_window.go:302 getWindowStart,
+// the formula the TopN pre-aggregation path already uses to assign tumbling
+// windows. It is exactly DATE_BIN with a Unix-epoch origin.
+func bucketStart(ts, width int64) int64 { return ts - ts%width }
+```
+
+#### The bucket group must NOT be a hash map over all buckets
+
+The obvious implementation — add the bucket to `keyIndices` and let `BatchAggregation`'s
+`map[string]*aggGroup` hold every `(bucket, tags…)` combination until the scan ends — is the
+wrong shape for a time-series database. It holds `buckets × tagGroups` live groups, blocks until
+the last row is read, and then needs a sort to put the series back in time order. All three costs
+are avoidable, because **the input is already sorted by time**.
+
+`applyMeasureQueryOrdering` (`banyand/measure/query.go:286`) sets the default explicitly:
+
+```go
+func applyMeasureQueryOrdering(mqo model.MeasureQueryOptions, result *queryResult) {
+	if mqo.Order == nil {
+		result.ascTS = true
+		result.orderByTS = true      // ← globally timestamp-ascending
+		return
+	}
+	…
+	result.orderByTS = mqo.Order.Type == index.OrderByTypeTime
+}
+```
+
+and `queryResult.Less` (`:913`) then makes the merge heap compare **timestamp first**, falling
+back to seriesID only to break ties. An aggregation request carries no `order_by` — the
+distributed analyzer explicitly skips OrderBy resolution when `Agg != nil`
+(`distributed.go:248`) — so `Order == nil`, and rows arrive in globally ascending timestamp
+order with no change to the scan at all. (The non-default branch, `orderByTS == false`, sorts by
+`sidToIndex` first and is series-major; that is the case the guard below exists for.)
+
+Sorted input means a bucket, once left behind, can never receive another row. So:
+
+> **Stream the buckets.** Keep groups for the *current* bucket only; when a row's bucket advances,
+> flush that bucket's groups downstream and reset the map.
+
+```
+rows (time-ascending) ──▶ [bucket B0 groups] flush ──▶ [B1 groups] flush ──▶ [B2 …]
+                           ^ map holds ONE bucket at a time
+```
+
+Three properties fall out, and they are the reason to prefer this over the map:
+
+| | Hash map over all buckets | Streaming |
+|---|---|---|
+| Live groups | `buckets × tagGroups` | `tagGroups` (one bucket) |
+| Emission | blocking — nothing until the scan ends | node-local pipelining — bucket *n* leaves the operator while *n+1* fills |
+| Output order | needs a sort pass | already bucket-ascending |
+
+The 1440× memory multiplier a 24-hour/1-minute query would otherwise carry simply does not exist,
+and `BatchAggregation`'s `BreakerOperator` contract (`vectorized/operator.go:63` — "buffers all
+input via Consume, then produces output via NextBatch") relaxes to a pipelined operator for the
+bucketed case. This also mirrors how the codebase already aggregates over time on the write path:
+`pkg/flow/streaming` assigns tumbling windows and emits them as they close, using the same
+`getWindowStart` formula.
+
+**Index mode does not get this guarantee, and must not stream.** `query.go:140-147` returns
+through the index-mode branch *before* the ordering setup above ever runs; `buildIndexQueryResult`
+passes `mqo.Order` through unchanged (`query.go:506-560`), and with a nil order
+`storage/index.go:350-374` takes the unsorted filter path and returns the underlying index search
+order. Timestamps being present in `SeriesData` is not an ordering guarantee. A legal index-mode
+input can therefore arrive in buckets B, A, B — which would reopen a flushed bucket, or trip the
+monotonicity guard on a query that is perfectly valid.
+
+> **Index-mode bucketed aggregation uses the non-streaming map** — groups keyed on
+> `(bucket, tags…)` held until the scan ends — rather than the streaming operator. Index mode
+> stays fully in scope (§2); it simply takes the safe path. Streaming is a part-path optimisation,
+> and the plan must choose between them on `measureSchema.GetIndexMode()`, not on a runtime guess.
+
+The alternative — forcing a verified timestamp sort ahead of the operator for index mode — buys
+back streaming at the cost of a blocking sort, which is what streaming existed to avoid. The map
+is the honest trade.
+
+**The guard is not optional.** Streaming is correct *only* while the input is time-ordered. If
+`orderByTS` were ever false on the part path, a bucket would be reopened after being flushed and
+the query would emit duplicate rows for the same bucket — a silently wrong series, the exact
+failure class this design exists to remove. Two defences, both required:
+
+1. **Analyzer** — reject (or refuse to stream) a bucketed request whose `order_by` resolves to
+   anything other than time ordering, since that flips `orderByTS` to false.
+2. **Operator** — assert monotonicity. A row whose bucket is *less* than the current bucket is an
+   invariant violation; fail loudly rather than emit a second row for a closed bucket. This is one
+   comparison per row and it converts a silent corruption into a loud error.
+
+Everything else downstream is unchanged: `keyIndices` gains the timestamp column index,
+`computeKey` encodes it, `newGroup` copies it with the existing `copyOneValue`, and every row in
+a group shares the bucket by construction.
+
+Three further consequences to handle:
+
+- **Reverse D2 conditionally.** `buildAggOutputLayout` (`aggregation.go:659`) must re-emit the
+  `RoleTimestamp` column when — and only when — a bucket key is present. Today it never does, and
+  `plan/build_test.go:221` asserts that; the assertion stays correct for the unbucketed case and
+  gains a bucketed sibling. The serializer then populates `DataPoint.timestamp` from it, which is
+  why §5.2 needs no new column name.
+- **Negative timestamps.** Go's `%` truncates toward zero, so `bucketStart` skews by one bucket
+  for `ts < 0`. Measure timestamps are Unix milliseconds and the write path rejects
+  non-positive ones, so this is unreachable — but the helper should floor correctly anyway
+  rather than depend on a caller's invariant.
+- **Bucket ≠ segment.** The bucket width is unrelated to `ResourceOpts.segment_interval`, which
+  is an `IntervalRule` restricted to HOUR/DAY and governs physical segmentation, not query
+  grouping. A bucket may span segments and a segment may contain many buckets; nothing in the
+  scan needs to know.
+
+**Distributed.** The bucket is a pure function of a value already on the row, so each data node
+floors independently and no coordination is needed. Node partials become per
+`(bucket, tag group)`; the liaison reduce must key on the bucket too. Two seams:
+`distributedGroupByTagNames` (`distributed.go:1131`) returns tag names only and needs to carry
+the bucket flag, and `resolveKeyIndices` (`reduce.go:169`) resolves `RoleTag` columns only and
+needs to resolve the `RoleTimestamp` column as the leading key.
+
+The streaming property survives the fan-out, because **every node emits buckets in ascending
+order**, so the liaison holds N sorted streams rather than N unordered heaps. That makes the
+reduce a k-way merge on the bucket key: a bucket closes as soon as every node has moved past it,
+and the liaison's live state is one bucket's groups rather than the whole window's. The same
+`buckets × tagGroups` blow-up is avoided on both sides.
+
+Node-side streaming is mandatory (it is where the scan-sized state lives). Liaison-side merging
+is a refinement worth separating, and the distinction matters for what the design is allowed to
+claim: the vec distributed path broadcasts and **collects every response before `executeAgg`**
+(`distributed.go:471-476`, `:638`), so the encoded payload is already fully resident. Merging
+saves the decoded group state, not the buffering.
+
+That is why §2's requirement is worded as *node-local* streaming. Calling the whole query
+pipelined while the liaison buffers all frames would be false. Genuine end-to-end pipelining
+would need ordered partial streams, a liaison k-way merge, and cancellation/backpressure plumbing
+— a separate piece of work, not a property this design delivers. A correct first cut
+hash-aggregates on `(bucket, tags…)` at the liaison, bounded by what the nodes chose to send.
+
+### 7.3 COUNT_DISTINCT on a single node
+
+A new `AggCountDistinct` function and a new `aggSlot` variant holding a set of encoded values,
+keyed by `appendKeyComponent` — the same encoder the group key already uses, which covers every
+target type the matrix admits.
+
+The set itself belongs in `pkg/query/aggregation` (a new `distinct.go`), keeping aggregation
+semantics in the package that owns them; `aggregation_test.go
+TestBatchAggregation_DelegatesToAggregationPackage` pins that convention.
+
+```go
+// Distinct accumulates the distinct values of one group.
+type Distinct interface {
+    In(key []byte) (added bool) // added is true on first sight; it drives the memory charge
+    Val() int64
+    Reset()
+}
+```
+
+`NewMap`/`NewReduce` (`aggregation.go:62`, `:82`) should keep returning `errUnknownFunc` for
+`COUNT_DISTINCT`. That is not an oversight but the mechanism by which a `COUNT_DISTINCT` TopN
+rule is rejected: `banyand/measure/topn_post_processor.go:285` calls `NewMap` and propagates the
+error rather than mis-aggregating.
+### 7.4 COUNT_DISTINCT across data nodes
+
+Every other function pushes down cleanly: the node reduces to a scalar `Partial` and the liaison
+combines. `COUNT_DISTINCT` appears to break that, because per-node counts cannot in general be
+summed — the same value seen on two nodes would count twice.
+
+**But it can be pushed down whenever shard placement partitions the value space, and that is
+decidable statically.** So `COUNT_DISTINCT` pushes down like everything else, and a request that
+cannot be pushed down is **rejected at analyze time** rather than served by a second, costlier
+mechanism.
+
+**The rejection is uniform across deployment shapes.** A standalone server has no sharding, so it
+could serve every `COUNT_DISTINCT` correctly — and deliberately does not. The condition is
+evaluated in the analyzer, which runs identically in both modes, so a query either works
+everywhere or nowhere. The alternative would let a query pass in a single-node development
+environment and fail on a cluster, which is a worse failure than never having supported it: the
+rule *the target must be the entity* is only teachable if it holds unconditionally.
+
+#### The decomposability condition
+
+Shard placement is deterministic and value-derived, but the values it derives from are **the
+effective routing key**, which is not always the entity. `Locate` computes an entity-based shard
+and then *overrides* it with the sharding-key router when one is configured
+(`pkg/partition/route.go:51-65`); the liaison installs both routes
+(`discovery.go:93-111`, sharding-key locator at `:428-452`), and the write path picks between
+them at `liaison/grpc/measure.go:176-181`.
+
+> **routingTags** = the measure's `sharding_key` when configured, otherwise its entity tags.
+
+This is a supported schema shape, not a rejected input: `CheckShardingKeySubset`
+(`api/validate/validate.go:228-255`) returns nil for a single entity tag, and a subset failure is
+logged as a *warning* during create/update (`metadata/schema/property/client.go:779-809`). The
+repo's own fixtures carry one — `service_instance_float_metric.json` has entity
+`[service_id, entity_id]` with sharding key `[service_id]`.
+
+Every row sharing a set of routing tag values lands on exactly one shard. If fixing the group key
+and the distinct value pins *every* routing tag, that `(group, value)` pair can exist on only one
+shard, the per-shard distinct sets for a group are **disjoint**, and disjoint cardinalities add:
+
+> **routingTags ⊆ groupByTags ∪ {target}** ⟹ per-shard counts are summable.
+
+**This is a sufficient condition, not a necessary one.** Fixed predicates in the query, or a
+proven functional dependency between tags, can equally pin a routing tag that is not grouped. The
+design does not model either, so some decomposable queries are rejected. That is a deliberate
+product gate — see *Policy, not necessity* below — and it should not be described as a
+mathematical boundary.
+
+**Why stating this over entity tags is wrong**, concretely: entity `{e}`, sharding key `{s}`, and
+`COUNT_DISTINCT(e)` with no grouping on `s`. An entity-based rule accepts it because the target
+*is* the entity. But the same `e` occurring under two `s` values routes to two shards, each
+reporting a local count of 1, summing to 2 for one distinct entity.
+
+When the condition holds, each node computes its own exact distinct count per `(bucket, group)`
+and emits **one int64** in the existing `Partial{Value}` shape; the liaison reduces with **SUM**.
+No new wire type, no new partial shape, and the whole `AggModeMap` → `AggModeReduce` machinery is
+reused verbatim: `COUNT_DISTINCT` is *"local distinct, global sum"*.
+
+#### Multi-stage requests are rejected
+
+`(stage, shard, group)` identity decides whether two partials are *replicas*. It does **not**
+make stage-local distinct counts additive, and an earlier draft wrongly assumed it did.
+
+Shards partition the *value* space; stages partition *time* — and a distinct value crosses time.
+Hot holds group `g` with target set `{e1}`, warm holds `g` with `{e1}`: the true whole-range
+answer is 1, and summing stage-local cardinalities gives 2. No migration overlap or duplicate
+timestamp is needed; the same target legitimately occurs at different times in different stages.
+
+**Bucketing does not rescue it, and the reason is worth recording.** It is tempting to argue that
+because stages do not overlap in time, a bucket falls inside one stage. But that non-overlap is
+not visible to the analyzer: `parseNodeSelector` (`dquery.go:161-189`) only builds *selectors* —
+it produces no stage time windows — and segment selection is by overlapping physical segment
+range and retention (`storage/segment.go:612-649`, `tsdb.go:277-299`), not by a query-time
+lifecycle boundary. Bucket width alone is not proof of single-stage ownership.
+
+> **Therefore: reject every resolved multi-stage `COUNT_DISTINCT`, bucketed or not.** Resolve the
+> requested stages — including the `DefaultStages` fallback, which may list several — and reject
+> if more than one survives. Never silently drop a stage.
+
+Lifting this later requires all of: a per-output-bucket read-ownership invariant that is
+*enforced and analyzer-visible*, stable effective routing, correct per-shard partials, and
+replica dedup. Short of that, the alternative is a value-bearing union across stages with its own
+memory budget — the fallback this design rejects on cost grounds elsewhere.
+
+A related caveat with the same shape: `ResourceOpts.ShardNum` can be changed by `UpdateGroup`
+(`metadata/schema/property/client.go:976-989`). A pure node rebalance that preserves logical
+shard ids is harmless, but a modulus change creates historical routing domains, and a single
+*current* shard count does not prove which modulus the older selected parts were written under.
+Treat unchanged `shard_num` over the queried range as an assumption to establish, not a given.
+
+#### The rule, and what it means in practice
+
+For the common SkyWalking shape the condition collapses to something easy to predict. Those
+measures declare both entity and sharding key as the single `entity_id` tag — visible in this
+repo's fixtures, e.g. `pkg/test/measure/testdata/measures/endpoint_resp_time_minute.json`:
+
+```json
+"entity":       { "tag_names": ["entity_id"] },
+"sharding_key": { "tag_names": ["entity_id"] },
+"interval": "1m"
+```
+
+With `routingTags = {entity_id}`, `COUNT_DISTINCT(entity_id)` satisfies
+`routingTags ⊆ groupBy ∪ {target}` for any grouping, and a distinct over a non-routing tag such
+as `service_id` does not.
+
+**The shorthand "distinct over the entity is always supported" is wrong** and was removed: it
+holds only when the routing key *is* the entity. A measure with a narrower sharding key —
+entity `[service_id, entity_id]`, sharding key `[service_id]`, as in
+`service_instance_float_metric.json` — behaves differently in both directions. There,
+`COUNT_DISTINCT(entity_id)` grouped by `service_id` is accepted because `{service_id}` is
+covered, while an ungrouped `COUNT_DISTINCT(entity_id)` is rejected. **Always evaluate the
+condition against `routingTags`, never against a remembered rule about entities.**
+
+Applied to the motivating workload, whose measures route on `entity_id`:
+
+| Case | Shape | Outcome |
+|---|---|---|
+| Distinct api-keys for one user | `COUNT_DISTINCT(entity_id) WHERE service_id = …` | ✅ pushed down |
+| Distinct api-keys per user | `GROUP BY service_id, COUNT_DISTINCT(entity_id)` | ✅ pushed down |
+| The same, per time bucket, single stage | `+ TIME_BUCKET(w)` | ✅ pushed down |
+| Any of the above resolving to **more than one stage** | — | ❌ rejected |
+| Distinct *models* per user, as modelled today | model is inside a serialized blob, not a tag | ❌ not expressible at any layer |
+| Distinct models, with the model promoted to an entity | structurally identical to the api-key case | ✅ pushed down |
+| "How many distinct users exist?" | `COUNT_DISTINCT(service_id)` — not a routing tag | ❌ rejected |
+
+Every case this workload expresses is pushed down, **provided it resolves to a single stage** —
+which is now a live constraint on the deployment, not a footnote, since an empty `stages` falls
+back to `DefaultStages` and that may list several.
+
+#### Why rejection rather than a fallback
+
+A fallback path — nodes shipping their distinct *values* for the liaison to union — is always
+correct and needs no condition check. It was considered and rejected (§9), because:
+
+- **Its cost is unbounded and user-controlled.** Wire volume and liaison memory scale with
+  cardinality, so one `COUNT_DISTINCT` over a high-cardinality tag funnels every value into a
+  single process. "Slow and correct" is a poor trade when the liaison is shared infrastructure;
+  the failure lands on every other query in flight.
+- **It makes the cost model unpredictable.** Two queries that look alike would differ by orders
+  of magnitude depending on a schema property the author cannot see in the query text. A hard
+  rule — *the target must be the entity* — is something a user can reason about in advance.
+- **It doubles the surface.** Two implementations of one function, a static chooser between them,
+  opposite replica-dedup requirements (union is idempotent, summation is not), and a differential
+  test to keep them honest — all to serve queries the data model says should be written
+  differently.
+- **It is inconsistent with the rest of this design**, which already rejects rather than
+  approximates: array and timestamp tags (§6), numeric functions over string tags (§6), bucket
+  a bucket width with no source to resolve it from (§5.3), tag aggregation on the row engine (§8).
+
+Rejection is also the honest signal. A `COUNT_DISTINCT` over a non-entity tag is usually a
+modelling mismatch, and the error should say so rather than silently serving it expensively:
+
+```
+count_distinct(service_id) is not supported: the target must be part of the measure's
+entity (entity_id), or every entity tag must appear in GROUP BY. Either group by
+service_id and count_distinct(entity_id), or model service_id as an entity tag.
+```
+
+The user is not stuck: `GROUP BY <target>` and counting the returned rows client-side still
+works exactly as it does today, and is the same shape the fallback would have implemented — only
+with the cost visible to the caller who chose it.
+
+#### Policy, not necessity
+
+Three of the rejections above are **product decisions and should be read as such**, not as
+mathematical boundaries:
+
+- The condition is *sufficient*, not necessary. Fixed predicates and proven functional
+  dependencies could pin an ungrouped routing tag; this design models neither.
+- Grouping by every effective routing tag can make a **non-routing target** decomposable. Such
+  queries are rejected because the analyzer does not reason about it, not because they are
+  unanswerable.
+- Rejecting uniformly in standalone (§7.4) forgoes queries a single node could union locally and
+  exactly. That is a portability choice — one contract regardless of deployment shape — and is
+  only defensible while it is stated deliberately rather than presented as a limit of the maths.
+
+Each is a place where the engine could later do more without changing any wire format. Recording
+them as policy keeps that door visible; describing them as necessity would quietly close it.
+
+#### De-duplicating the partials
+
+Replicas of a shard hold identical data, so selecting one copy per replica set is the right
+mechanism. It needs two fixes to be correct, because today's deduped unit is neither a shard nor
+a stage.
+
+**(a) Today's partial is per-node, and its shard label is a hint.** `computeKey`
+(`aggregation.go:523`) builds the group key from `keyIndices` — the group-by tags only — and
+`newGroup` stamps the group with the shard id of the *first row that created it*. A node holding
+shards 1 and 2 emits **one** partial for group `g` covering both, labelled with whichever shard
+appeared first. "The replicas are all the same" is true of shards and not of these partials.
+
+Within a node each shard appears exactly once — there are no duplicate shards on a node — so a
+per-shard partial is always well defined. That property is what makes the fix below *correct*;
+it is not what makes today's labelling *sufficient*. What breaks today is that node shard-*sets*
+differ, and under replication they do, by construction:
+
+```go
+// pkg/node/round_robin.go:218
+func (r *roundRobinSelector) selectNode(index int, replicasID uint32) string {
+	adjustedIndex := index + int(replicasID)
+	return r.nodes[adjustedIndex%len(r.nodes)]
+}
+```
+
+Placement is *staggered*, so with 4 nodes, 4 shards and `replicas=1`:
+
+| | shard 0 | shard 1 | shard 2 | shard 3 |
+|---|---|---|---|---|
+| replica 0 | n0 | n1 | n2 | n3 |
+| replica 1 | n1 | n2 | n3 | n0 |
+
+giving `n0={s0,s3}`, `n1={s0,s1}`, `n2={s1,s2}`, `n3={s2,s3}`. No node holds a shard twice, every
+shard is on exactly two nodes, and **no two nodes hold the same set** — all three at once. `Broadcast` (`queue/pub/pub.go:309`) reaches all four
+(there is no shard-level query routing, and `segment.Tables()` exposes every shard a node holds
+with no primary/replica distinction), so four partials arrive for group `g`, each covering two
+shards and labelled with one. Deduping on `(shard_label, group)` then either drops a whole
+two-shard partial or keeps two that overlap. Concretely, for a group present in `s0` and `s1`:
+`n0` reports `s0`, `n2` reports `s1`, and `n1` reports `s0+s1` under whichever label its first
+row carried. If `n1` labels itself `s0` the answer is right by luck; if it labels itself `s1`,
+`s0` is counted twice. Same data, same topology, different answer depending on row order.
+
+At `replicas=0` the table collapses to one row, node sets are disjoint, every shard is covered
+exactly once and the dedup never fires — consistent with this not having been observed.
+
+> **Fix:** in the map phase, add the shard-id column index to `keyIndices`. Each emitted row is
+> then exactly one shard's contribution, by construction rather than by labelling. The shard is
+> already on every input row — `BuildBatchSchema` puts `RoleShardID` in its fixed prefix
+> (`integration.go:64-69`) — so this is one entry in a slice, not new plumbing.
+>
+> This does not replace replica dedup; it is the precondition that makes it correct. Once each
+> partial *is* one shard's contribution, "the replicas are all the same, so keep one" becomes
+> literally true of the rows being deduped, and `markDedupSeen` does exactly the right thing.
+
+**(b) The shard id is not unique across stages.** Where more than one stage is in play, hot and
+warm nodes answer one broadcast and both report shard 3 for group `g` with **different, disjoint**
+data. They are not replicas of each other, so deduping on `(shard, group)` would drop one — an
+undercount, the opposite failure from (a).
+
+This is about *replica identity only*. It is emphatically **not** an argument that stage-local
+distinct counts can be summed once the stages are distinguishable; they cannot, which is why
+multi-stage `COUNT_DISTINCT` is rejected outright above. The identity matters here because
+`SUM`/`COUNT`/`MEAN` — which *are* additive across stages — run through the same reduce and are
+not rejected.
+
+Note also that no stage identity exists in the partial wire schema today
+(`vectorized/measure/frame/frame.go:119-175`), so it has to be added for the reduce to key on it.
+
+> **Fix:** the identity is `(stage, shard, group_key)`, for the additive functions. The stage is
+> known at broadcast time and must travel with the partial.
+
+**(c) The liaison then dedups and sums**, which is `AggModeReduce` unchanged apart from the key:
+
+```
+per-node partials ──▶ drop repeats of (stage, shard, group)   ← replicas: identical data, idempotent
+                  ──▶ SUM survivors per group                 ← shards: disjoint in value space
+                                                                 (stages: additive functions only;
+                                                                  DISTINCT is single-stage by rule)
+```
+
+`markDedupSeen` (`aggregation_reduce.go:83`) already builds `LE64(shardID) ++ groupKey`; it needs
+the stage prepended and the guarantee from (a) that the shard field is exact.
+
+**Cost.** Partials are one int64 per `(stage, shard, group)` **that holds data** — a group whose
+values live on 3 shards emits 3 rows, not `shard_num` rows. Wire volume is therefore independent
+of cardinality: 1M distinct values cost at most `shard_num` integers per group.
+
+> ⚠️ **The fix ships with this work; the investigation runs alongside it.** Both (a) and (b)
+> describe today's `SUM`/`COUNT`/`MEAN` reduce, not something `COUNT_DISTINCT` introduces — so if
+> the analysis holds, a replicated cluster (`replicas ≥ 1`) is already mis-aggregating every
+> distributed `agg` query, silently and non-deterministically.
+>
+> That does **not** make it a sequencing blocker. Making the partial per-shard is a small,
+> self-contained change that is correct whether or not the defect reproduces today: it costs one
+> extra entry in `keyIndices` and bounded extra partial rows, and it is the precondition that
+> makes replica dedup mean what it says. Build it as part of stage 5 rather than waiting on an
+> investigation to authorise it.
+>
+> Run the experiment in parallel, because its outcome decides something the code change does not
+> — whether this is *also* a live defect that existing deployments need told about:
+>
+> - stand up 4 data nodes, 4 shards, `replicas=1`, write a known dataset, and compare
+>   `SUM(value)` from a distributed `agg` query against the same sum computed from a raw
+>   (non-agg) scan of the same range;
+> - repeat at `replicas=0`, where the analysis predicts agreement.
+>
+> Divergence at 1 and agreement at 0 confirms it. If it reproduces, the per-shard change is not
+> merely a refinement — it is a correctness fix for `SUM`/`COUNT`/`MEAN` that warrants its own
+> entry in `CHANGES.md`, and possibly a patch release, independently of this design. Same code
+> either way; a materially different thing to communicate.
+
+#### Composition and touch points
+
+`groupKeys` above is whatever §7.2 produced — with `time_bucket` set it is `[bucket] ++ tagKeys`,
+so the bucket is just another leading key column and the two features compose without a special
+case. Push-down composes especially well with the streaming design: the node's distinct set is
+released when a bucket closes, so it emits a scalar per `(bucket, group)` and never holds more
+than one bucket's values.
+
+Touch points: `distributedAggFunc` (`:1142`) gains an entry mapping `COUNT_DISTINCT` to the
+existing SUM reducer; `BuildOperators` (`plan.go:61`) builds the local distinct slot in the map
+phase and adds the shard to `keyIndices`; `markDedupSeen` takes the stage; the analyzer gains the
+condition check and its rejection. `nodeTemplate.Limit` is already `MaxUint32` whenever
+`Agg != nil` (`:222`), which remains harmless here since partials are per-group scalars.
+### 7.5 Ranking, ordering and paging
+
+**Ranking is unchanged.** `ApplyTopToReduce` (`reduce.go:290`) resolves `RoleField && Name ==
+top.FieldName` on the reduced batch, and the aggregate result is exactly such a column;
+`offset`/`limit` are applied by `iteratorFromBatches` (`distributed.go:988`). The ranked case
+therefore falls out of §7.4 with no extra work.
+
+**Ordering needs no sort pass.** Because §7.2 streams buckets in ascending order, and
+`BatchAggregation` already emits groups in insertion order (`BatchAggregation.insertion`), a
+bucketed result is bucket-ascending by construction — on each node, and at the liaison after the
+k-way merge. Ordering by the bucket ascending — the default a bucketed query wants — is satisfied by the scan
+ordering the engine already provides, not by re-sorting the aggregate. Within a bucket, groups
+keep insertion order unless `top` or `order_by` says otherwise.
+
+This is the second dividend of streaming: a sort over `buckets × tagGroups` rows would have been
+cheap in absolute terms but would have re-imposed a blocking stage on an otherwise pipelined
+query, forfeiting the latency win. The third is that the bucket-advance check this operator
+already performs is exactly the span boundary run-folding needs (§7.7).
+
+Ordering and `top` interact in the obvious way — `top` selects globally (§3), then the surviving
+rows are emitted in bucket order. A client wanting "the global top 20 devices, plotted over time"
+gets exactly that; a client wanting per-bucket top-N wants the TopN RPC.
+
+### 7.6 Memory safety
+
+A distinct set grows with cardinality, unlike the fixed-size numeric slots, so it must be charged
+**per inserted value**, not per group. `BatchAggregation.Consume` already reserves `entrySize`
+per new group from the shared `MemoryTracker` (`aggregation.go:326-333`); the distinct path
+additionally reserves on each `added` value and refunds the total in `Close`. A high-cardinality
+target then fails with the existing `"aggregation memory budget exceeded"` message against the
+256 MiB default (`VectorizedConfig.QueryMemoryMiB`).
+
+**This also requires fixing `reduce.go:133`, which passes `entrySize=0`** — the liaison reduce is
+unbudgeted today. That is tolerable for scalar partials and not tolerable for sets, since the
+liaison is exactly where the union of every node's distinct values lands. Note this is a
+**node-side** concern specifically. Because §7.4 pushes down, the liaison holds no sets at all —
+one int64 per `(stage, shard, group)` — so the sets that need charging live only on the nodes,
+where the scan-sized state already is.
+
+**Time bucketing does not multiply the live group count**, because §7.2 streams: a bucketed query
+holds one bucket's groups, not `buckets × tagGroups`. This is the main reason the streaming shape
+matters rather than being a mere optimisation — the naive map would have made a 24-hour/1-minute
+query cost 1440× the unbucketed group count, and for `COUNT_DISTINCT` each of those 1440× groups
+would have held its own set.
+
+What bucketing *does* change is the shape of the peak: the live set is now the widest single
+bucket rather than the whole window. A bucket with pathological cardinality still fails the
+budget, and that is the correct outcome — but it fails on one bucket's data, which is both a
+smaller number and a far more interpretable error than "the query as a whole was too big".
+
+Two bounds keep this defensible:
+
+- **Per bucket**, live groups ≤ distinct `(tags…)` in that bucket ≤ rows in that bucket. The
+  aggregation can never hold more than the scan hands it for the bucket it is on.
+- **Across the window**, bucket count is `time_range / width`. Streaming means that no longer
+  governs memory, but it still governs *output* rows, which is what `limit` must accommodate
+  (§10). A very small width against a long window is therefore a paging problem rather than a
+  memory one.
+
+---
+
+### 7.7 The local scan: entity and indexed tags never touch the data file
+
+A `COUNT_DISTINCT` over a tag, grouped by a bucket and other tags, can in the common case be
+answered **without decoding a single tag column** — because the tag values are already resident
+from the series index, and the only thing the scan needs from the part is which series appear in
+which bucket.
+
+**The projection is split before the data file is reached.** `searchSeriesList`
+(`query.go:314-356`) sorts every requested tag into one of three buckets:
+
+| Tag kind | Resolved from | Reaches the block cursor? |
+|---|---|---|
+| **Entity tag** | `projectedEntityOffsets` → `SeriesList[].EntityValues` (`query.go:486-493`) | **no** — `continue TAG` |
+| **Indexed tag** (`is.fieldIndexLocation`) | `indexProjection` → the series index `fieldResult` | **no** — `continue TAG` |
+| anything else | the part's tag columns | yes, via `newTagProjection` |
+
+Only the third category is appended to `newTagProjection`, and only `newTagProjection` becomes
+`bc.tagProjection`. So `unmarshalTagFamily` never reads a column for an entity or indexed tag,
+and `copyAllTo` (`block.go:535-552`) takes the value from `indexValue[tagName]` and skips the
+block column outright. Both kinds are series-scoped — one value replicated across the block's
+rows — which is precisely why they can live outside the data file at all.
+
+**What a pure index-resolved aggregation actually reads** — valid only when *every* required tag
+(group keys, aggregation target, and any tag a residual predicate touches) resolves from the
+series index. One part-resident tag anywhere in the query reinstates the `.tfm`/`.tf` reads for
+its family, so this is a property of the whole query, not of the aggregation target alone:
+
+| File | Read? |
+|---|---|
+| `sidx/` (series index) | once per segment — series list plus projected index values |
+| `meta.bin` | at part open only; the primary-block index stays resident |
+| `primary.bin` | selectively, per matching series |
+| `timestamps.bin` | per block — but see below |
+| `<family>.tfm` / `.tf` | **never** |
+| `field.bin` | **never** — this query shape projects no field |
+
+**It goes one step further than "metadata plus timestamps".** `blockMetadata` carries
+`seriesID`, `timestamps.min`, `timestamps.max` and `count`, all resident after
+`readPrimaryBlock` with no data-file read. When `bucketOf(min) == bucketOf(max)` the whole block
+lies inside one bucket, so the series' presence in that bucket is known from metadata alone and
+`timestamps.bin` need not be read either — **but only if the block is also fully contained in the
+query's time range**. Block selection is range-*aware*, not range-*clipped*: it returns any block
+whose `[min,max]` overlaps the requested range (`query.go:594-629`). A block with rows at 09:00
+and 09:59 occupies a single hourly bucket, yet a query for 09:30–09:31 matches neither row.
+Testing `bucketOf(min) == bucketOf(max)` alone would report a bucket that has no matching data. The scan degenerates to:
+
+```
+for each block matching (series filter, time range):
+    group key  ← seriesID → routing tag values      (series index, already in hand)
+    bucket(s)  ← [min,max] ONLY IF the block is fully inside the query range
+                 AND min/max share a bucket AND no residual predicate can exclude rows;
+                 otherwise read timestamps.bin and filter
+    count distinct seriesID per (bucket, group)
+```
+
+All three conditions are required. Dropping the containment check reports empty buckets;
+dropping the predicate check reports buckets whose rows are all filtered out.
+
+For `COUNT_DISTINCT` over a tag that is **functionally determined by the series** — an entity
+tag, which every series pins by construction — this is exact by identity: counting distinct
+values *is* counting distinct series, and a series either appears in a bucket or it does not. It
+does **not** extend to an arbitrary index-resolved tag: an indexed non-entity tag is
+series-constant, so it is still readable without the data file, but several series may share a
+value, and the count must then go through the distinct set rather than the series count.
+
+Four things bound the optimisation, and they should be settled before it is built:
+
+- **Straddling *or* partially-selected blocks still need `timestamps.bin`.** A block spanning
+  09:00–11:00 with no rows in the 10:00 bucket must not report presence there, so `[min,max]`
+  cannot be over-approximated into a bucket range; and a block only partly inside the query range
+  must be filtered rather than assumed present. How much the shortcut actually buys therefore
+  depends on bucket width against block span *and* on how often a block sits wholly inside the
+  requested range — which is why the payoff is worth measuring before this stage is built rather
+  than assumed from the bucket width alone.
+- **It applies to `COUNT` and `COUNT_DISTINCT` only.** `SUM`/`MIN`/`MAX`/`MEAN` over a field
+  obviously need field data, and over a non-entity, non-indexed tag any function needs the tag
+  column.
+- **`blockMetadata.count` is not a row count for `COUNT`.** It counts stored entries including
+  superseded versions, while the query path dedups `(seriesID, timestamp)` keeping the highest
+  version (`queryResult.merge`). Using it directly would over-count duplicates. The identity
+  holds for distinct-series counting, which is version-insensitive, and fails for row counting —
+  a sharp distinction worth encoding in a test.
+- **It is a separate execution strategy, not a tweak.** The pipeline today decodes blocks into
+  `MeasureResult` rows and feeds the vec operators; this bypasses block decode entirely. Bounded
+  work, but a new path with its own correctness surface — so it belongs after the main design
+  lands, gated on a differential test against the decoding path.
+
+#### Intermediate step: fold by run, not by row
+
+The metadata-only scan above is the end state. There is a cheaper step on the way to it that
+needs **no new execution strategy at all**, only a change inside the aggregation operator — and
+it removes most of the same waste.
+
+An index-resolved tag is series-constant, but nothing downstream knows that, so the single value
+is multiplied out three times before it is folded:
+
+1. **Replicated.** `copyAllTo` expands one value into `n` pointers —
+   `for i := 0; i < size; i++ { t.Values[i] = indexValue[tagName] }`.
+2. **Materialised.** `appendTagValueAsTyped` appends it into the native column one row at a time,
+   so the column physically holds `n` entries of the same value.
+3. **Folded.** `BatchAggregation.Consume` is row-oriented: per row it calls `computeKey`, probes
+   `a.groups[key]`, and folds. `computeKey` ends in `return string(buf)`, and that string is
+   stored in the group map, so it escapes — **a heap allocation per row**. Go's
+   `m[string(b)]` no-allocation optimisation does not apply across the function boundary.
+
+So a block costs `n` × {key encode, string allocation, map hash + probe + compare, fold}, and
+from the second row onward every one of those reproduces an identical result. For
+`COUNT_DISTINCT` the `n−1` redundant set inserts are idempotent no-ops that still pay a hash and
+a probe each. `n` here is rows per series per block, bounded by `maxBlockLength = 8192`
+(`banyand/measure/measure.go:46`).
+
+**Two facts make run-folding almost free to add:**
+
+- **A batch is single-series.** `BuildMeasureBatchFromResult` consumes exactly one
+  `model.MeasureResult`, and `queryResult.merge` returns as soon as the seriesID changes — so
+  `seriesID` never varies inside a batch and needs no checking.
+- **§7.2 already finds the boundaries.** The streaming operator walks the timestamp column
+  looking for bucket advances. Because input is timestamp-ascending and the series is fixed, the
+  rows form *contiguous* spans of constant `(series, bucket)` — and a bucket advance is exactly a
+  span boundary. The detection is already there; only the folding is per-row.
+
+**The change:** for each maximal span sharing `(series, bucket)`, compute the key once, resolve
+the group once, and fold the span in one operation.
+
+| Aggregate | Per span |
+|---|---|
+| `COUNT_DISTINCT` over a series-constant target | **one** set insert |
+| `COUNT` | `count += spanLength` |
+| `SUM` / `MIN` / `MAX` / `MEAN` over a varying column | still per row — but the key work is saved |
+
+**Magnitude.** 8192 rows of 1-minute data is roughly 5.7 days inside one block; at hour buckets
+that is about 137 spans, so 8192 key computations, allocations and map probes collapse to ~137.
+Unbucketed, 8192 collapses to 1.
+
+**What it needs:** a plan-time flag marking which key columns are index-resolved, which
+`searchSeriesList`'s three-way split already determines — the information exists, it simply is
+not carried forward today. Correctness rests on the series-constancy guarantee, so the flag must
+be derived from that split rather than inferred by sampling column values at runtime; a column
+that merely *happens* to be constant in one batch must not take this path.
+
+Ordering relative to §7.7's metadata-only scan: this step is strictly cheaper than today, strictly
+simpler than the metadata-only scan, and the two are compatible — run-folding helps every
+aggregation whose keys are index-resolved, including the ones that still have to read a part-file
+column for the aggregate itself, which the metadata-only path cannot serve at all.
+
+**`index_mode` measures are this section's structural case.** They hold no part-resident tags at
+all — `buildIndexQueryResult` errors on any projected tag that is neither an entity offset nor an
+indexed field — so an index-mode aggregation is index-only by construction rather than by
+fortunate schema design. Two consequences: the `.tf`/`.tfm` rows of the table above do not merely
+go unread, they do not exist; and the "straddling blocks" bound does not apply, since there are
+no data blocks to straddle. Index mode should therefore be the first place the metadata-only path
+is built and measured.
+
+Note the asymmetry with §7.2: index mode is the *best* case for scan cost and the *worst* case
+for ordering, since it bypasses the timestamp-ordering default entirely. It reads least and
+streams least.
+
+One caveat for run-folding specifically: index mode materialises one row per index entry
+(`copyTo` builds a single-timestamp `MeasureResult`), so the multi-row single-series batch that
+run-folding collapses may not arise on that path. Run-folding targets the part-file scan; the
+batch-oriented `PullBatch` path for index mode should be checked before assuming it benefits.
+
+This is also an argument for the §7.4 modelling guidance from the other direction: putting the
+distinct target in the routing key does not merely make the query *pushable*, it makes the local
+scan metadata-only.
+
+## 8. The deprecated row engine
+
+`pkg/query/logical/measure` rejects all three new shapes — `agg.tag_name`, `COUNT_DISTINCT`, and
+`group_by.time_bucket` — with
+`"tag aggregation and time bucketing require the vectorized measure engine
+(--measure-vectorized-enabled=true)"`.
+
+The package carries an explicit *"Do not extend with new features"* note
+(`measure_analyzer.go:62-68`), the vectorized engine is on by default, and implementing parity in
+the row iterators would roughly double the work to protect a rollback path that is itself being
+retired. A clear error is more honest than a second implementation that will rot.
+
+---
+
+## 9. Alternatives considered
+
+| Alternative | Why rejected |
+|---|---|
+| **Aggregate inside the native inverted index** | The engine already aggregates locally on data nodes, so this would not reduce network cost — it only moves which layer owns local aggregation, while duplicating grouping, arithmetic, memory accounting and distributed reduction. Estimated 5–8+ engineer-weeks against 2–4 for extending the engine. |
+| **HyperLogLog partials** | Constant wire cost and no decomposability condition to check, but it needs a new partial wire type and an API contract about error bounds. §7.4 already gets cardinality-independent wire *exactly* whenever the condition holds, which covers the motivating shape — so a sketch would buy only the queries §7.4 rejects, and would buy them approximately. That is the right thing to revisit if those rejections turn out to matter, and the wrong thing to build speculatively. |
+| **A value-shipping fallback** for queries that fail the condition — nodes ship distinct values, liaison unions | Always correct and needs no condition check, but its wire volume and liaison memory scale with cardinality, so a single query can funnel every value into shared infrastructure. It also doubles the surface: two implementations, a static chooser, opposite replica-dedup requirements, and a differential test to keep them honest. Rejecting is consistent with how this design treat [...]
+| **Ship per-node counts and sum them** | Wrong. A value appearing on two nodes is counted twice; that is the same class of bug as the multi-bucket `COUNT(*)` this design removes. |
+| **Emit an explicit set-typed partial column** | Same wire volume as shipping distinct tuples, but needs a new column type, a new frame wire type, and a new `Partial` shape. Reusing the group-by shape costs nothing extra. |
+| **Solve it upstream instead** — change SkyWalking to store labelled metric values as tags | An OAP core change touching all three storage plugins, and it multiplies row count by label cardinality — the packed representation exists precisely to prevent that. Out of scope here regardless, since it does not remove the need for `COUNT_DISTINCT`. |
+| **Requiring the width to be a multiple of `Measure.interval`** | Rejected after the fact that motivated it turned out not to hold: `interval` is a declared write cadence, not a storage invariant, so data points do not reliably land on interval boundaries. With arbitrary timestamps every width bins just as meaningfully as any other, and the rule would refuse useful queries to protect an alignment that was never guaranteed. §5.3. |
+| **A `multiplier` integer instead of a duration string** | Makes invalid widths unrepresentable, but `multiplier: 5` cannot be read without the measure schema in hand — it means 5 minutes or 5 hours depending on the measure. A duration string matches `Measure.interval`'s own encoding, parses with the same helper, and matches the `INTERVAL <width>` spelling the bucketing functions in §1 use. Validation is one modulo. |
+| **Bucket on `segment_interval`** rather than `Measure.interval` | `ResourceOpts.segment_interval` is an `IntervalRule` restricted to HOUR/DAY and describes physical segmentation, not data-point cadence. It is both too coarse and semantically unrelated. |
+| **Hash-map the bucket** — add it to `keyIndices` and hold every `(bucket, tags…)` until the scan ends | The path of least resistance, and wrong for a time-series database. It holds `buckets × tagGroups` live groups (1440× for a day at 1-minute), blocks all output until the last row, and then needs a sort to restore time order — all three paid for information the scan already provides, since measure results arrive timestamp-ascending by default (`query.go:286`). §7.2 streams instead. |
+| **Reuse the TopN RPC for series** | `TopNResponse.lists` is already a per-timestamp series, but the RPC ranks a single pre-aggregated field by a pre-declared `TopNAggregation` rule. It cannot express an ad-hoc `COUNT_DISTINCT` over a tag, and it requires schema registration ahead of the query. |
+
+---
+
+## 10. Compatibility and follow-ups
+
+- **Wire.** All three API additions are additive. Old clients never set `tag_name` or
+  `time_bucket`, and never receive a `COUNT_DISTINCT` response.
+- **No new configuration.** `COUNT_DISTINCT` either pushes down or the request is rejected, so
+  there is no flag, no default to choose and no second path to keep warm. The §7.4 per-shard
+  partial ships as part of that single path rather than behind a gate, and is correct on its own
+  terms; only the question of whether it *also* fixes a live defect in `SUM`/`COUNT` is deferred
+  to the parallel experiment.
+- **Mixed-version clusters.** A liaison that sends a `COUNT_DISTINCT` request to an older data
+  node gets the node's existing unknown-function error rather than a wrong number, because
+  `protoAggFuncToInternal` (`plan.go:167`) rejects enum values it does not know. **`time_bucket`
+  has no such guard** — an old node silently ignores the unknown proto field and returns
+  whole-range partials, which the liaison would otherwise reduce into one row per tag group with
+  no error: a series collapsed to a point, silently.
+
+  The check is **self-describing**, needing no capability negotiation. A bucketed query's partial
+  carries a `RoleTimestamp` column, because §7.2 reverses D2 exactly when a bucket key is
+  present. A node that ignored the field emits partials *without* that column, so the liaison
+  detects the absence directly and hard-errors. This is deliberately not a node-version or
+  capability lookup: `database.v1.Node` carries only `metadata`, `roles`, addresses and a generic
+  `labels` map, so no such mechanism exists to consult — and inventing one to answer a question
+  the data already answers would be the wrong trade.
+- **`limit` with a bucketed query.** Result rows are `buckets × tagGroups`, so the default
+  `limit` of 100 (`measure_analyzer.go:32 defaultLimit`) truncates a 1-hour/1-minute/50-device
+  series at 100 of its 3000 rows. The default is left alone — changing it would alter unbucketed
+  queries too — but clients must set `limit` explicitly, and this needs to be prominent in the
+  BydbQL docs rather than buried in the API reference.
+- **Possible follow-up: multiple aggregates, then aliases.** Out of scope here by decision (§3),
+  and recorded only so the route stays visible if the remainder of §1.1 is ever wanted.
+  `AggSpec` is already a slice and `BatchAggregation` already loops over `a.aggs`, so the
+  constraint is the singular `QueryRequest.agg` field and BydbQL's one-aggregate rule — API and
+  planning, not execution. Aliases need a BydbQL `AS` production and an
+  `Aggregation.output_name` field together, and the two share one question: what a result column
+  is called when there is more than one.
+- **Follow-up: per-bucket `top`.** If demand appears, the honest move is to extend the TopN RPC
+  rather than overload `QueryRequest.top`, since TopN already returns a per-timestamp series.
+- **Follow-up: gap filling.** Needs a declared bucket set and a zero value per aggregate; SUM and
+  COUNT fill with 0, MIN/MAX/MEAN have no defensible fill.
+
+### Known adjacent inconsistency (note, do not fix here)
+
+`reduce.go:169 resolveKeyIndices` matches key tags by `Name` only, ignoring `TagFamily`, unlike
+`plan.go:140` and `distributed.go:945`, which both compare the family. It is safe in practice —
+tag names are unique per measure (§5.1) — and `distributedGroupByTagNames` (`:1131`) discards the
+family before calling it anyway. New code should resolve on `(family, name)`.
+
+---
+
+## 11. Testing strategy
+
+**Unit**
+
+- `pkg/query/aggregation` — **this package has no tests at all today.** Add set semantics, reset,
+  and the `NewMap`/`NewReduce` rejection that guards the TopN path.
+- `vectorized/measure/aggregation_test.go` — its `aggIntSchema()`/`aggFloatSchema()` fixtures use
+  a `RoleField` agg column exclusively; they need a tag-input sibling. Cover the numeric
+  functions over an INT tag, `COUNT` over a string tag, `COUNT_DISTINCT` over string/int/bytes,
+  null exclusion, budget exhaustion on a high-cardinality set, and the distinct-tuple map shape.
+- `vectorized/measure/plan_test.go:73-78` enumerates the five functions explicitly and must gain
+  the sixth; likewise `test/cases/measure/cmd/generate/layer3_features.go:197`, which hard-codes
+  `{"MEAN","MAX","MIN","COUNT","SUM"}` in the generated feature matrix. Both under-cover a new
+  function silently — the `exhaustive` linter will not catch either, because the enum switches
+  have `default` branches.
+- `plan/analyzer_test.go` — the full §6 rejection matrix, plus implicit projection and `HideTag`.
+  For bucketing, the §5.3 table is the test table: width from the request; width absent but
+  interval present; both absent (reject); unparseable, zero and negative widths (reject). A width
+  that is *not* a multiple of the interval must be **accepted** — that is the case the removed
+  rule would have wrongly rejected.
+- `plan/distributed_test.go` — node template, liaison reduce over synthetic frames, replica
+  idempotence, and `top` + `offset` over the distinct result.
+- **The `COUNT_DISTINCT` push-down condition (§7.4) is the thing to test hardest**, because a
+  mis-evaluated condition does not crash — it returns an inflated count that looks entirely
+  plausible. There is no fallback path to diff against, so the tests must pin the predicate
+  directly and pin the rejection.
+  - **Accept/reject table over schemas**, driven by `routingTags` not entity tags:
+    `routingTags ⊆ groupBy ∪ target` (accept); a routing tag in neither (reject); `index_mode`
+    with the same containment (**accept** — §7.4); an unresolvable tag, routing key or stage
+    (reject — indeterminate must not be permissive, and must not panic).
+  - **The sharding-key counterexample**, which an entity-based rule accepts and must not: entity
+    `{e}`, sharding key `{s}`, rows carrying the same `e` under two `s` values, and
+    `COUNT_DISTINCT(e)` with no grouping on `s`. Must reject. Summing scalar partials here
+    returns 2 for one distinct entity.
+  - **Multi-stage rejection**: the same query must be rejected when the resolved stage set has
+    more than one entry, bucketed or not — and the resolution must expand `DefaultStages`, not
+    only an explicit `stages` list. Pair it with the positive case: the same target in two
+    *disjoint buckets* of a single stage must count once per bucket and must **not** be rejected
+    for that reason.
+  - **Ambiguous tag name**: two families each containing `region`; an aggregation naming `region`
+    must resolve by its family qualifier, and an unqualified reference must fail naming both
+    families rather than silently binding to whichever family `RegisterTag` wrote last.
+  - **The rejection is a contract, not an implementation detail.** Assert the error is returned
+    at analyze time, before any node is contacted, and that its text names the target, the
+    entity, and the two ways out. A test on the message is warranted here because the message is
+    the entire remediation path for a user who has hit it.
+  - **An overlapping fixture must be rejected, not silently summed**: the same distinct value
+    present on two shards for one group. This is precisely the data shape that makes push-down
+    wrong, so what is under test is that the condition catches it rather than that the arithmetic
+    handles it.
+  - **Replica duplication must be deduped**: feed the same `(stage, shard, group)` partial twice
+    and assert the count does not double. Then feed two partials differing only in shard, and
+    assert they *do* add — the two halves of §7.4(c), which fail in opposite directions.
+  - **Standalone/distributed agreement**: the same query and fixture must return the same number
+    single-node and across nodes. Single-node has no sharding, so this is the cheapest end-to-end
+    check that the partition arithmetic is right.
+  - **Replicated-topology agreement** (§7.4): the same query and fixture must return the same
+    number at `replicas=0` and `replicas=1`. This is the regression test the per-shard partial
+    exists for, and it is the one that would have caught the defect the 4a experiment is
+    investigating — so it belongs in the suite permanently, not just in the investigation.
+- **Bucketing needs its own unit coverage**, and two properties matter more than the happy path:
+  - `bucketStart` agrees with `getWindowStart` (`sliding_window.go:302`) across a table of
+    timestamps and widths, including a boundary timestamp (`ts % width == 0` stays put) and a
+    negative one. Asserting the two formulas agree keeps the query path and the TopN
+    pre-aggregation path from drifting into two definitions of "the 5-minute bucket".
+  - `plan/build_test.go:221` currently asserts the output schema drops the timestamp. That
+    assertion must stay for the unbucketed case and gain a bucketed sibling asserting the
+    timestamp **is** present and carries the bucket start — the two together pin D2 as
+    conditional rather than repealed.
+  - **`COUNT` and `COUNT_DISTINCT` must be allowed to disagree at the interval width.** An
+    earlier draft proposed asserting they are *equal* when bucketed at exactly
+    `Measure.interval`, on the belief that storage holds one row per `(entity, bucket)`. It does
+    not — the interval is a declared write cadence (§5.3) — so that test would fail on perfectly
+    legal data. Invert it: seed one entity with two off-cadence points inside one bucket
+    (`09:01:00` and `09:01:34` on a 1-minute measure) and assert `COUNT` reports 2 while
+    `COUNT_DISTINCT` reports 1. That is the whole reason the function exists, and it is the
+    fixture a naive implementation passes only by accident.
+- **The §7.7 metadata shortcut needs its range guard tested**: one block with rows at 09:00 and
+  09:59 (a single hourly bucket) queried over 09:30–09:31 must yield **no** bucket row; the same
+  block fully inside the range must take the shortcut. Without the first case the optimisation
+  reports buckets that contain nothing.
+- **Index-mode ordering** (§7.2): index-mode rows whose index order is deliberately
+  non-chronological — timestamps arriving A, B, A — must still produce one result per bucket. This
+  is the case that proves index mode took the map rather than the streaming operator, and it fails
+  loudly if someone later "optimises" it onto the streaming path.
+- **The streaming operator needs tests for the property it depends on, not just its output**
+  (§7.2):
+  - **Bucket closure**: after the input advances past bucket *n*, the operator has released
+    bucket *n*'s groups. Assert on live state, not just emitted rows — a version that emits
+    correctly while retaining everything passes an output-only test and fails the whole point of
+    the design.
+  - **Monotonicity guard**: feed deliberately out-of-order timestamps and assert a loud error
+    rather than duplicate rows for a reopened bucket. This is the silent-corruption path, so it
+    needs a test that would fail if someone later removes the check as redundant.
+  - **Pipelining**: the first bucket's output is available before the source is exhausted. A
+    `BreakerOperator`-shaped implementation passes every correctness test and fails this one.
+  - **Ordering assumption**: a test pinning `applyMeasureQueryOrdering`'s `Order == nil` →
+    `orderByTS = true` default (`query.go:286`), since the streaming operator's correctness rests
+    on it and nothing in the vec package would otherwise notice if that default changed.
+
+**Integration** — `test/cases/measure/`
+
+Cases are `input/<name>.yaml` + `input/<name>.ql` + golden `want/<name>.yaml`, registered as a
+`g.Entry` in `measure.go`; `data.go verifyQLWithRequest` proto-compares the QL transform against
+the YAML request, so both inputs are required.
+
+The load-bearing case is a **multi-bucket cardinality fixture**: one user with 3 api-keys spread
+across 3 hour buckets, a second user with 2, asserting `3` and `2` for the scalar case and the
+correct ordering for the ranked, paged case. It must run in the **distributed** suite
+(`test/integration/distributed/query/vectorized_test.go`, liaison + 2 data nodes) as well as
+standalone — the cross-node union has no single-node analogue, and a fixture confined to one
+bucket is exactly what lets the naive `COUNT(*)` over-count slip through unnoticed.
+
+The same fixture serves the series case at a second bucket width, and that is the point of
+reusing it: at a 1-hour bucket the same data must yield one row per `(hour, user)` whose counts
+sum to neither 3 nor 2, while at whole-range it must yield exactly 3 and 2. A fixture that only
+ever asks one width cannot catch a bucket key that is computed but ignored.
+
+**The decisive test for bucketing is a single-bucket decomposition check.** The oracle is
+constructed *by the test*, not borrowed from any client: a bucketed query over a window must
+equal the union of one-bucket-wide queries over the same window, because each of those is exact
+by the storage invariant (§1, workaround 1).
+
+```
+for each bucket B in window:                        one bucketed query over the window
+    query(time_range = B, group_by = tags)    ==    (group_by = TIME_BUCKET(w), tags)
+```
+
+Run both against the same fixture and compare row-for-row, keyed by `(bucket, tags…)`. This is
+worth more than any hand-written golden file, because each single-bucket query is independently
+trustworthy — it is the one shape the engine already answers exactly — so the comparison has a
+real oracle rather than a recorded expectation. It catches the whole class of off-by-one
+bucket-boundary errors that a golden file would simply enshrine. It also gives any caller
+currently issuing per-bucket queries a migration check they can run themselves before switching
+to a single bucketed query.
+
+Golden files must be **timestamp-stable**. `data.go` ignores timestamps when comparing, which is
+precisely the field a bucketed result asserts on, so these cases need the comparison to include
+it — otherwise the suite would pass with every bucket collapsed to zero.
+
+**Manual**
+
+```sql
+-- Scalar case — one user, 3 buckets. Must return 3, not 6.
+SELECT COUNT(DISTINCT entity_id) FROM MEASURE … WHERE service_id = '<user>' TIME > '-3h';
+
+-- Ranked case — all users, ranked, paged.
+SELECT service_id, COUNT(DISTINCT entity_id) FROM MEASURE … TIME > '-3h'
+GROUP BY service_id ORDER BY entity_id DESC LIMIT 20;
+
+-- Series case — one row per (5-minute bucket, host).
+SELECT host, COUNT(host) FROM MEASURE … TIME > '-1h'
+GROUP BY TIME_BUCKET('5m'), host LIMIT 1000;
+```
+
+---
+
+## 12. Delivery order
+
+Each stage compiles and is testable on its own.
+
+| # | Stage | Principal files |
+|---|---|---|
+| 0 | Proto (`COUNT_DISTINCT`, `agg.tag_name`, `group_by.time_bucket`), regeneration, `model.MeasureAgg` + `model.MeasureGroupBy` | `api/proto/banyandb/{model,measure}/v1/*.proto`, `pkg/query/model/model.go` |
+| 1 | Tag binding for the existing five functions, standalone and distributed | `plan/analyzer.go`, `plan.go`, `integration.go`, `aggregation.go` |
+| 2 | Time bucketing: `bucketStart`, `BatchTimeBucket`, streaming close + monotonicity guard on the part path, non-streaming map for index mode, conditional D2 reversal | `vectorized/measure/{aggregation,plan}.go`, `plan/analyzer.go`, new bucket operator |
+| 3 | Time bucketing distributed: bucket key in the node template, then the liaison k-way merge on bucket | `plan/distributed.go`, `reduce.go` |
+| 4 | `COUNT_DISTINCT` single node, with the per-value memory charge | `pkg/query/aggregation/distinct.go`, `aggregation.go`, `groupby.go` |
+| 4a | **Runs in parallel with 5, does not gate it**: the 4-node/`replicas=1` experiment (§7.4). Its outcome decides whether stage 5's per-shard partial is also a correctness fix for existing `SUM`/`COUNT`/`MEAN` — i.e. whether it needs a `CHANGES.md` entry and a possible patch release | test harness; `CHANGES.md` if confirmed |
+| 5 | `COUNT_DISTINCT` distributed: push-down condition + rejection, shard in the map-phase key, `(stage, shard, group)` dedup, SUM reduce | `plan/analyzer.go`, `plan.go`, `aggregation.go`, `aggregation_reduce.go`, `plan/distributed.go` |
+| 6 | Guards: row-path rejection, §5.3 width resolution, §6 matrix, multi-stage rejection, ambiguous-tag rejection, TopN, the `time_bucket` staleness check | `pkg/query/logical/measure/measure_analyzer.go`, `plan/analyzer.go` |
+| 7 | BydbQL (`COUNT(DISTINCT …)`, `TIME_BUCKET(…)`) and the `bydbctl` planner | `pkg/bydbql/{grammar,transformer}.go`, `bydbctl/internal/tui/planner/plan_types.go` |
+| 8 | Tests and docs, including `index_mode` coverage in the accept/reject table and the integration fixtures | above, plus `docs/interacting/bydbql.md`; `docs/api-reference.md` is regenerated, never hand-edited |
+| 8a | *Optional*: run-folding — fold each `(series, bucket)` span once instead of per row (§7.7). No new execution path; needs the index-resolved flag carried into the plan | `vectorized/measure/aggregation.go`, `plan/analyzer.go`, `plan/dispatch.go` |
+| 9 | *Optional, after 8a*: metadata-only scan for entity/indexed-tag aggregation (§7.7), behind a differential test against the decoding path | `banyand/measure/query.go`, `part_iter.go`, `plan/dispatch.go` |
+
+Stages 2–3 (time bucketing) and 4–5 (`COUNT_DISTINCT`) are independent of each other and both
+build on stage 1. Either pair can ship first; the ordering above front-loads bucketing because it
+is the smaller change and because it exercises the conditional-D2 output schema that stage 5 also
+depends on. Nothing in the table waits on 4a: the per-shard partial it concerns is built in
+stage 5 on its own merits, and 4a only determines how the result is *communicated*.
+
+Generated protobuf code is gitignored and rebuilt by `make -C api generate`. The full gate is
+`make pre-push`.
diff --git a/docs/design/README.md b/docs/design/README.md
index 379a3a01b..2fea03586 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -1,3 +1,15 @@
-# Design Archive
+# Design Documents
 
-All BanyanDB design documents are preserved as historical records under [`archive/`](archive/README.md), grouped by the milestone in which they were introduced. New design work must be added to the corresponding milestone directory when it is introduced.
+Design documents are grouped by the milestone in which they were introduced. The current
+milestone sits directly under this directory and holds live working documents; new design work
+must be added there when it is introduced. When a milestone ships, its directory moves into
+[`archive/`](archive/README.md).
+
+## Current milestone — 0.12.0
+
+- [Native inverted-index replacement](0.12.0/native-inverted-index/README.md)
+- [Tag aggregation and time bucketing in the measure query engine](0.12.0/tag-aggregation/README.md)
+
+## Shipped milestones
+
+See [`archive/`](archive/README.md) for 0.11.0 and earlier.
diff --git a/docs/design/archive/README.md b/docs/design/archive/README.md
index ee8cecf65..979f2828c 100644
--- a/docs/design/archive/README.md
+++ b/docs/design/archive/README.md
@@ -1,7 +1,9 @@
 # Archived Design Documents
 
-Design documents are grouped by their introducing milestone. They are historical records; preserve their technical content when reorganizing them.
+Design documents for shipped milestones, grouped by their introducing milestone. They are
+historical records; preserve their technical content when reorganizing them. Designs for the
+current milestone live one level up, under [`docs/design/`](../README.md), and move here when
+that milestone ships.
 
-- [0.10.0](0.10.0/README.md) — FODC and KTM designs.
 - [0.11.0](0.11.0/README.md) — trace-pipeline and trace-merge designs.
-- [0.12.0](0.12.0/README.md) — native inverted-index replacement design package.
+- [0.10.0](0.10.0/README.md) — FODC and KTM designs.