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

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 065206eed feat(kiro): add core models and report parser (#9073)
065206eed is described below

commit 065206eedfbf232457fd6f3b6ec4f344724c57bb
Author: Warren Chen <[email protected]>
AuthorDate: Thu Aug 27 18:57:26 2026 +0800

    feat(kiro): add core models and report parser (#9073)
    
    Signed-off-by: warren <[email protected]>
---
 backend/plugins/kiro/models/chat_log.go            |  86 ++++++
 backend/plugins/kiro/models/completion_log.go      |  63 +++++
 backend/plugins/kiro/models/connection.go          | 139 +++++++++
 backend/plugins/kiro/models/connection_test.go     |  89 ++++++
 .../kiro/models/migrationscripts/20260729_init.go  |  48 ++++
 .../kiro/models/migrationscripts/archived/init.go  | 168 +++++++++++
 .../kiro/models/migrationscripts/register.go       |  30 ++
 backend/plugins/kiro/models/s3_file_meta.go        |  75 +++++
 backend/plugins/kiro/models/s3_slice.go            | 164 +++++++++++
 backend/plugins/kiro/models/s3_slice_test.go       | 110 ++++++++
 backend/plugins/kiro/models/user_model_messages.go |  57 ++++
 backend/plugins/kiro/models/user_report.go         |  78 ++++++
 backend/plugins/kiro/tasks/normalize.go            | 171 +++++++++++
 backend/plugins/kiro/tasks/normalize_test.go       | 273 ++++++++++++++++++
 .../01_earliest_no_email_no_newuser.csv            |   2 +
 .../tasks/testdata/user_report/02_standard_cli.csv |   2 +
 .../tasks/testdata/user_report/03_standard_ide.csv |   2 +
 .../user_report/04_newuser_after_models.csv        |   2 +
 .../user_report/05_prefixed_userid_upper_bool.csv  |   2 +
 .../tasks/testdata/user_report/06_two_users.csv    |   3 +
 .../user_report/07_kiro_web_nonzero_overage.csv    |   2 +
 .../testdata/user_report/08_plugin_multi_model.csv |   2 +
 backend/plugins/kiro/tasks/user_report_parser.go   | 294 +++++++++++++++++++
 .../plugins/kiro/tasks/user_report_parser_test.go  | 312 +++++++++++++++++++++
 24 files changed, 2174 insertions(+)

diff --git a/backend/plugins/kiro/models/chat_log.go 
b/backend/plugins/kiro/models/chat_log.go
new file mode 100644
index 000000000..ecd943696
--- /dev/null
+++ b/backend/plugins/kiro/models/chat_log.go
@@ -0,0 +1,86 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// KiroChatLog is one GenerateAssistantResponse interaction.
+//
+// Neither the prompt nor the assistant response text is stored. Derived
+// features are computed from them during extraction and the originals are
+// discarded, which keeps proprietary code and personal content out of the
+// warehouse while preserving the signal.
+//
+// RequestId is the key rather than (UserId, Timestamp) because log timestamps
+// carry nanosecond precision that MySQL DATETIME(6) truncates - two records
+// within the same microsecond would collide.
+type KiroChatLog struct {
+       common.NoPKModel
+       ConnectionId uint64 `gorm:"primaryKey"`
+       ScopeId      string `gorm:"primaryKey;type:varchar(255)"`
+       RequestId    string `gorm:"primaryKey;type:varchar(64)"`
+
+       // UserId is the bare UUID; log records always carry an identity-store
+       // prefix, which is stripped so this joins against the report table.
+       UserId string `gorm:"type:varchar(64);index" json:"userId"`
+       // IdentityStoreId is the stripped prefix, retained for auditability.
+       IdentityStoreId string    `gorm:"type:varchar(32)" 
json:"identityStoreId"`
+       Timestamp       time.Time `gorm:"type:datetime(6);index" 
json:"timestamp"`
+       // ChatTriggerType is MANUAL or INLINE_CHAT per the docs; only MANUAL 
has
+       // been observed. Not validated against a fixed set.
+       ChatTriggerType string `gorm:"type:varchar(20)" json:"chatTriggerType"`
+       // ModelId is present on only about 45% of records, hence nullable. Do 
not
+       // use it for model attribution; see KiroUserModelMessage.
+       ModelId *string `gorm:"type:varchar(100)" json:"modelId"`
+
+       // HasPrompt distinguishes a user turn from an agent self-continuation.
+       //
+       // An empty prompt means the agent continued on its own after a tool 
call;
+       // a non-empty one means the user spoke. The ratio between them 
measures how
+       // much of the traffic the agent generates versus the human - roughly 
71% of
+       // sampled records have no prompt.
+       HasPrompt    bool `gorm:"index" json:"hasPrompt"`
+       PromptLength int  `json:"promptLength"`
+       // PromptSha256 identifies the same prompt being resubmitted, which is a
+       // rework signal. NULL when the prompt is empty: hashing the empty 
string
+       // would give ~71% of rows one shared hash and destroy the signal.
+       PromptSha256 *string `gorm:"type:char(64);index" json:"promptSha256"`
+
+       ResponseLength     int  `json:"responseLength"`
+       HasFollowupPrompts bool `json:"hasFollowupPrompts"`
+
+       // HasSteering and IsSpecMode are heuristics derived from prompt text, 
so
+       // they are meaningful only when HasPrompt is true and NULL otherwise -
+       // storing false would be indistinguishable from a real negative.
+       HasSteering *bool `json:"hasSteering"`
+       IsSpecMode  *bool `json:"isSpecMode"`
+
+       // ConversationId and UtteranceId are documented but absent from every
+       // sampled record. Kept as nullable columns in case they appear later; 
their
+       // absence is why the S3 logs cannot group interactions into sessions.
+       ConversationId *string `gorm:"type:varchar(64)" json:"conversationId"`
+       UtteranceId    *string `gorm:"type:varchar(64)" json:"utteranceId"`
+}
+
+func (KiroChatLog) TableName() string {
+       return "_tool_kiro_chat_log"
+}
diff --git a/backend/plugins/kiro/models/completion_log.go 
b/backend/plugins/kiro/models/completion_log.go
new file mode 100644
index 000000000..0d09c28e0
--- /dev/null
+++ b/backend/plugins/kiro/models/completion_log.go
@@ -0,0 +1,63 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// KiroCompletionLog is one GenerateCompletions (inline suggestion) request.
+//
+// The counters are named Returned*, not Accepted*: a record is written when 
the
+// suggestion is requested, not when it is accepted. Empty completions arrays 
are
+// common, so these numbers measure what Kiro offered rather than what the
+// developer took.
+//
+// Note also that FileName is a bare file name with no directory path, so it
+// cannot be resolved to a unique repository file.
+type KiroCompletionLog struct {
+       common.NoPKModel
+       ConnectionId uint64 `gorm:"primaryKey"`
+       ScopeId      string `gorm:"primaryKey;type:varchar(255)"`
+       RequestId    string `gorm:"primaryKey;type:varchar(64)"`
+
+       UserId          string    `gorm:"type:varchar(64);index" json:"userId"`
+       IdentityStoreId string    `gorm:"type:varchar(32)" 
json:"identityStoreId"`
+       Timestamp       time.Time `gorm:"type:datetime(6);index" 
json:"timestamp"`
+
+       // FileName has no path component; see the type comment.
+       FileName      string `gorm:"type:varchar(255);index" json:"fileName"`
+       FileExtension string `gorm:"type:varchar(50)" json:"fileExtension"`
+       // HasCustomization records whether a customization ARN was attached. 
Present
+       // on completion records but never on chat records.
+       HasCustomization bool `json:"hasCustomization"`
+
+       // CompletionsCount is often 0 - the request was logged, no suggestion 
came
+       // back. Such records are still stored.
+       CompletionsCount   int `json:"completionsCount"`
+       ReturnedCharCount  int `json:"returnedCharCount"`
+       ReturnedLineCount  int `json:"returnedLineCount"`
+       LeftContextLength  int `json:"leftContextLength"`
+       RightContextLength int `json:"rightContextLength"`
+}
+
+func (KiroCompletionLog) TableName() string {
+       return "_tool_kiro_completion_log"
+}
diff --git a/backend/plugins/kiro/models/connection.go 
b/backend/plugins/kiro/models/connection.go
new file mode 100644
index 000000000..d8479c99b
--- /dev/null
+++ b/backend/plugins/kiro/models/connection.go
@@ -0,0 +1,139 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/utils"
+       helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+// Default S3 prefixes. Kiro's console writes user activity reports and
+// interaction logs under separate prefixes, and recommends (but does not
+// require) separate buckets for the two.
+const (
+       DefaultReportPrefix    = "user-report"
+       DefaultPromptLogPrefix = "logging"
+)
+
+// KiroConn holds the essential information to connect to the AWS S3 buckets
+// that Kiro exports to.
+type KiroConn struct {
+       // AccessKeyId for AWS
+       AccessKeyId string `mapstructure:"accessKeyId" json:"accessKeyId"`
+       // SecretAccessKey for AWS
+       SecretAccessKey string `mapstructure:"secretAccessKey" 
json:"secretAccessKey"`
+       // Region of the buckets, and of the Kiro profile that writes to them
+       Region string `mapstructure:"region" json:"region"`
+
+       // Bucket holding the user activity report CSVs
+       Bucket string `mapstructure:"bucket" json:"bucket"`
+       // ReportPrefix within Bucket; defaults to DefaultReportPrefix when 
empty
+       ReportPrefix string `mapstructure:"reportPrefix" json:"reportPrefix"`
+
+       // PromptLogBucket holding the interaction logs. Kiro recommends a 
bucket
+       // separate from the report one; when empty this falls back to Bucket so
+       // that single-bucket and dual-bucket setups share one code path.
+       PromptLogBucket string `mapstructure:"promptLogBucket" 
json:"promptLogBucket"`
+       // PromptLogPrefix within PromptLogBucket; defaults to
+       // DefaultPromptLogPrefix when empty
+       PromptLogPrefix string `mapstructure:"promptLogPrefix" 
json:"promptLogPrefix"`
+
+       // IdentityStoreId for AWS IAM Identity Center. Optional: it only 
resolves
+       // display names. User identity is keyed on the User_Email column of the
+       // report CSV, so collection works fully without it.
+       IdentityStoreId string `mapstructure:"identityStoreId" 
json:"identityStoreId"`
+       // IdentityStoreRegion may differ from the S3 region. Optional.
+       IdentityStoreRegion string `mapstructure:"identityStoreRegion" 
json:"identityStoreRegion"`
+
+       // RateLimitPerHour limits the requests sent to AWS
+       RateLimitPerHour int `mapstructure:"rateLimitPerHour" 
json:"rateLimitPerHour"`
+}
+
+// GetReportPrefix returns the report prefix with its default applied.
+func (conn *KiroConn) GetReportPrefix() string {
+       if p := strings.Trim(strings.TrimSpace(conn.ReportPrefix), "/"); p != 
"" {
+               return p
+       }
+       return DefaultReportPrefix
+}
+
+// GetPromptLogBucket returns the interaction log bucket, falling back to the
+// report bucket when no separate one is configured.
+func (conn *KiroConn) GetPromptLogBucket() string {
+       if b := strings.TrimSpace(conn.PromptLogBucket); b != "" {
+               return b
+       }
+       return strings.TrimSpace(conn.Bucket)
+}
+
+// GetPromptLogPrefix returns the interaction log prefix with its default
+// applied.
+func (conn *KiroConn) GetPromptLogPrefix() string {
+       if p := strings.Trim(strings.TrimSpace(conn.PromptLogPrefix), "/"); p 
!= "" {
+               return p
+       }
+       return DefaultPromptLogPrefix
+}
+
+// UsesSeparateBuckets reports whether reports and logs live in different
+// buckets, which determines whether both need an access check.
+func (conn *KiroConn) UsesSeparateBuckets() bool {
+       return conn.GetPromptLogBucket() != strings.TrimSpace(conn.Bucket)
+}
+
+func (conn *KiroConn) Sanitize() KiroConn {
+       conn.SecretAccessKey = utils.SanitizeString(conn.SecretAccessKey)
+       return *conn
+}
+
+// Deliberately no GetEndpoint/GetProxy/GetRateLimitPerHour here.
+//
+// Implementing plugin.ApiConnection would let this type flow into DevLake's
+// shared scope-browsing helpers, but those construct an HTTP client from the
+// endpoint and run a DNS check on it - and a bucket name is not a hostname, so
+// the check fails. Rather than return a fake endpoint that looks dialable, the
+// plugin implements its scope endpoints directly against the AWS SDK.
+
+// KiroConnection holds KiroConn plus ID/Name for database storage
+type KiroConnection struct {
+       helper.BaseConnection `mapstructure:",squash"`
+       KiroConn              `mapstructure:",squash"`
+}
+
+func (KiroConnection) TableName() string {
+       return "_tool_kiro_connections"
+}
+
+func (connection KiroConnection) Sanitize() KiroConnection {
+       connection.KiroConn = connection.KiroConn.Sanitize()
+       return connection
+}
+
+func (connection *KiroConnection) MergeFromRequest(target *KiroConnection, 
body map[string]interface{}) error {
+       secretKey := target.SecretAccessKey
+       if err := helper.DecodeMapStruct(body, target, true); err != nil {
+               return err
+       }
+       modifiedSecretKey := target.SecretAccessKey
+       if modifiedSecretKey == "" || modifiedSecretKey == 
utils.SanitizeString(secretKey) {
+               target.SecretAccessKey = secretKey
+       }
+       return nil
+}
diff --git a/backend/plugins/kiro/models/connection_test.go 
b/backend/plugins/kiro/models/connection_test.go
new file mode 100644
index 000000000..946edcee0
--- /dev/null
+++ b/backend/plugins/kiro/models/connection_test.go
@@ -0,0 +1,89 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+// The fallback rules exist so that a single-bucket setup (what real exports
+// currently look like) and the dual-bucket layout Kiro recommends both work
+// without branching anywhere else in the plugin.
+func TestConnectionPrefixAndBucketFallback(t *testing.T) {
+       t.Run("single bucket with defaults", func(t *testing.T) {
+               conn := &KiroConn{Bucket: "kiro-export-test"}
+               assert.Equal(t, "user-report", conn.GetReportPrefix())
+               assert.Equal(t, "logging", conn.GetPromptLogPrefix())
+               assert.Equal(t, "kiro-export-test", conn.GetPromptLogBucket())
+               assert.False(t, conn.UsesSeparateBuckets())
+       })
+
+       t.Run("single bucket with explicit prefixes", func(t *testing.T) {
+               conn := &KiroConn{
+                       Bucket:          "kiro-export-test",
+                       ReportPrefix:    "user-report",
+                       PromptLogPrefix: "logging",
+               }
+               assert.Equal(t, "user-report", conn.GetReportPrefix())
+               assert.Equal(t, "logging", conn.GetPromptLogPrefix())
+               assert.False(t, conn.UsesSeparateBuckets())
+       })
+
+       t.Run("separate buckets", func(t *testing.T) {
+               conn := &KiroConn{
+                       Bucket:          "reports-bucket",
+                       PromptLogBucket: "logs-bucket",
+               }
+               assert.Equal(t, "reports-bucket", conn.Bucket)
+               assert.Equal(t, "logs-bucket", conn.GetPromptLogBucket())
+               assert.True(t, conn.UsesSeparateBuckets())
+       })
+
+       t.Run("prefixes are stripped of slashes and whitespace", func(t 
*testing.T) {
+               conn := &KiroConn{
+                       Bucket:          " kiro-export-test ",
+                       ReportPrefix:    "/custom-report/",
+                       PromptLogPrefix: " /custom-logs/ ",
+               }
+               assert.Equal(t, "custom-report", conn.GetReportPrefix())
+               assert.Equal(t, "custom-logs", conn.GetPromptLogPrefix())
+               // Whitespace-only difference must not read as two buckets.
+               assert.False(t, conn.UsesSeparateBuckets())
+       })
+
+       t.Run("whitespace-only prompt log bucket falls back", func(t 
*testing.T) {
+               conn := &KiroConn{Bucket: "b", PromptLogBucket: "   "}
+               assert.Equal(t, "b", conn.GetPromptLogBucket())
+               assert.False(t, conn.UsesSeparateBuckets())
+       })
+}
+
+func TestConnectionSanitize(t *testing.T) {
+       conn := KiroConnection{
+               KiroConn: KiroConn{
+                       AccessKeyId:     "AKIAEXAMPLE",
+                       SecretAccessKey: "super-secret-value",
+               },
+       }
+       sanitized := conn.Sanitize()
+       assert.NotEqual(t, "super-secret-value", sanitized.SecretAccessKey)
+       // The key id is not a secret and stays readable for troubleshooting.
+       assert.Equal(t, "AKIAEXAMPLE", sanitized.AccessKeyId)
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/20260729_init.go 
b/backend/plugins/kiro/models/migrationscripts/20260729_init.go
new file mode 100644
index 000000000..4b2c8e7bb
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/20260729_init.go
@@ -0,0 +1,48 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/helpers/migrationhelper"
+       
"github.com/apache/incubator-devlake/plugins/kiro/models/migrationscripts/archived"
+)
+
+type initTables struct{}
+
+func (*initTables) Up(basicRes context.BasicRes) errors.Error {
+       return migrationhelper.AutoMigrateTables(
+               basicRes,
+               &archived.KiroConnection{},
+               &archived.KiroS3Slice{},
+               &archived.KiroS3FileMeta{},
+               &archived.KiroUserReport{},
+               &archived.KiroUserModelMessage{},
+               &archived.KiroChatLog{},
+               &archived.KiroCompletionLog{},
+       )
+}
+
+func (*initTables) Version() uint64 {
+       return 20260729000001
+}
+
+func (*initTables) Name() string {
+       return "Initialize schema for the kiro plugin"
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/archived/init.go 
b/backend/plugins/kiro/models/migrationscripts/archived/init.go
new file mode 100644
index 000000000..5c1c9a322
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/archived/init.go
@@ -0,0 +1,168 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package archived holds frozen copies of the model structs as they were when
+// each migration was written. Migrations reference these instead of the live
+// models so that editing a live model never changes what a historical 
migration
+// does.
+package archived
+
+import (
+       "time"
+
+       
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
+)
+
+type KiroConnection struct {
+       archived.Model
+       Name                string `gorm:"type:varchar(100);uniqueIndex" 
json:"name"`
+       AccessKeyId         string `gorm:"type:varchar(255)" json:"accessKeyId"`
+       SecretAccessKey     string `gorm:"type:varchar(255)" 
json:"secretAccessKey"`
+       Region              string `gorm:"type:varchar(100)" json:"region"`
+       Bucket              string `gorm:"type:varchar(255)" json:"bucket"`
+       ReportPrefix        string `gorm:"type:varchar(512)" 
json:"reportPrefix"`
+       PromptLogBucket     string `gorm:"type:varchar(255)" 
json:"promptLogBucket"`
+       PromptLogPrefix     string `gorm:"type:varchar(512)" 
json:"promptLogPrefix"`
+       IdentityStoreId     string `gorm:"type:varchar(255)" 
json:"identityStoreId"`
+       IdentityStoreRegion string `gorm:"type:varchar(100)" 
json:"identityStoreRegion"`
+       RateLimitPerHour    int    `json:"rateLimitPerHour"`
+}
+
+func (KiroConnection) TableName() string {
+       return "_tool_kiro_connections"
+}
+
+type KiroS3Slice struct {
+       archived.NoPKModel
+       ConnectionId  uint64 `gorm:"primaryKey"`
+       ScopeConfigId uint64 `json:"scopeConfigId,omitempty"`
+       Id            string `gorm:"primaryKey;type:varchar(512)" json:"id"`
+       BasePath      string `gorm:"type:varchar(512)" json:"basePath"`
+       AccountId     string `gorm:"type:varchar(255);not null" 
json:"accountId"`
+       Year          int    `gorm:"not null" json:"year"`
+       Month         *int   `json:"month"`
+}
+
+func (KiroS3Slice) TableName() string {
+       return "_tool_kiro_s3_slices"
+}
+
+type KiroS3FileMeta struct {
+       archived.NoPKModel
+       ConnectionId  uint64     `gorm:"primaryKey"`
+       S3Path        string     `gorm:"primaryKey;type:varchar(512)"`
+       FileName      string     `gorm:"type:varchar(255)" json:"fileName"`
+       Bucket        string     `gorm:"type:varchar(255)" json:"bucket"`
+       ScopeId       string     `gorm:"type:varchar(255);index" json:"scopeId"`
+       FileType      string     `gorm:"type:varchar(32);index" json:"fileType"`
+       Processed     bool       `gorm:"default:false" json:"processed"`
+       ProcessedTime *time.Time `gorm:"default:null" json:"processedTime"`
+       RecordCount   int        `gorm:"default:0" json:"recordCount"`
+       ErrorMessage  string     `gorm:"type:text" json:"errorMessage"`
+       AttemptCount  int        `gorm:"default:0" json:"attemptCount"`
+}
+
+func (KiroS3FileMeta) TableName() string {
+       return "_tool_kiro_s3_file_meta"
+}
+
+type KiroUserReport struct {
+       archived.NoPKModel
+       ConnectionId       uint64    `gorm:"primaryKey"`
+       ScopeId            string    `gorm:"primaryKey;type:varchar(255)"`
+       UserId             string    `gorm:"primaryKey;type:varchar(64)"`
+       Date               time.Time `gorm:"primaryKey;type:date"`
+       ClientType         string    `gorm:"primaryKey;type:varchar(20)"`
+       IdentityStoreId    string    `gorm:"type:varchar(32)" 
json:"identityStoreId"`
+       UserEmail          *string   `gorm:"type:varchar(255);index" 
json:"userEmail"`
+       DisplayName        *string   `gorm:"type:varchar(255)" 
json:"displayName"`
+       ProfileId          string    `gorm:"type:varchar(512)" json:"profileId"`
+       SubscriptionTier   string    `gorm:"type:varchar(50)" 
json:"subscriptionTier"`
+       IsNewUser          *bool     `json:"isNewUser"`
+       ChatConversations  int       `json:"chatConversations"`
+       TotalMessages      int       `json:"totalMessages"`
+       CreditsUsed        float64   `json:"creditsUsed"`
+       OverageCap         float64   `json:"overageCap"`
+       OverageCreditsUsed float64   `json:"overageCreditsUsed"`
+       OverageEnabled     bool      `json:"overageEnabled"`
+}
+
+func (KiroUserReport) TableName() string {
+       return "_tool_kiro_user_report"
+}
+
+type KiroUserModelMessage struct {
+       archived.NoPKModel
+       ConnectionId uint64    `gorm:"primaryKey"`
+       ScopeId      string    `gorm:"primaryKey;type:varchar(255)"`
+       UserId       string    `gorm:"primaryKey;type:varchar(64)"`
+       Date         time.Time `gorm:"primaryKey;type:date"`
+       ClientType   string    `gorm:"primaryKey;type:varchar(20)"`
+       ModelName    string    `gorm:"primaryKey;type:varchar(100)"`
+       MessageCount int       `json:"messageCount"`
+}
+
+func (KiroUserModelMessage) TableName() string {
+       return "_tool_kiro_user_model_messages"
+}
+
+type KiroChatLog struct {
+       archived.NoPKModel
+       ConnectionId       uint64    `gorm:"primaryKey"`
+       ScopeId            string    `gorm:"primaryKey;type:varchar(255)"`
+       RequestId          string    `gorm:"primaryKey;type:varchar(64)"`
+       UserId             string    `gorm:"type:varchar(64);index" 
json:"userId"`
+       IdentityStoreId    string    `gorm:"type:varchar(32)" 
json:"identityStoreId"`
+       Timestamp          time.Time `gorm:"type:datetime(6);index" 
json:"timestamp"`
+       ChatTriggerType    string    `gorm:"type:varchar(20)" 
json:"chatTriggerType"`
+       ModelId            *string   `gorm:"type:varchar(100)" json:"modelId"`
+       HasPrompt          bool      `gorm:"index" json:"hasPrompt"`
+       PromptLength       int       `json:"promptLength"`
+       PromptSha256       *string   `gorm:"type:char(64);index" 
json:"promptSha256"`
+       ResponseLength     int       `json:"responseLength"`
+       HasFollowupPrompts bool      `json:"hasFollowupPrompts"`
+       HasSteering        *bool     `json:"hasSteering"`
+       IsSpecMode         *bool     `json:"isSpecMode"`
+       ConversationId     *string   `gorm:"type:varchar(64)" 
json:"conversationId"`
+       UtteranceId        *string   `gorm:"type:varchar(64)" 
json:"utteranceId"`
+}
+
+func (KiroChatLog) TableName() string {
+       return "_tool_kiro_chat_log"
+}
+
+type KiroCompletionLog struct {
+       archived.NoPKModel
+       ConnectionId       uint64    `gorm:"primaryKey"`
+       ScopeId            string    `gorm:"primaryKey;type:varchar(255)"`
+       RequestId          string    `gorm:"primaryKey;type:varchar(64)"`
+       UserId             string    `gorm:"type:varchar(64);index" 
json:"userId"`
+       IdentityStoreId    string    `gorm:"type:varchar(32)" 
json:"identityStoreId"`
+       Timestamp          time.Time `gorm:"type:datetime(6);index" 
json:"timestamp"`
+       FileName           string    `gorm:"type:varchar(255);index" 
json:"fileName"`
+       FileExtension      string    `gorm:"type:varchar(50)" 
json:"fileExtension"`
+       HasCustomization   bool      `json:"hasCustomization"`
+       CompletionsCount   int       `json:"completionsCount"`
+       ReturnedCharCount  int       `json:"returnedCharCount"`
+       ReturnedLineCount  int       `json:"returnedLineCount"`
+       LeftContextLength  int       `json:"leftContextLength"`
+       RightContextLength int       `json:"rightContextLength"`
+}
+
+func (KiroCompletionLog) TableName() string {
+       return "_tool_kiro_completion_log"
+}
diff --git a/backend/plugins/kiro/models/migrationscripts/register.go 
b/backend/plugins/kiro/models/migrationscripts/register.go
new file mode 100644
index 000000000..ff85e7572
--- /dev/null
+++ b/backend/plugins/kiro/models/migrationscripts/register.go
@@ -0,0 +1,30 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+// All returns all migration scripts. Every new script must be added here or it
+// will never run.
+func All() []plugin.MigrationScript {
+       return []plugin.MigrationScript{
+               new(initTables),
+       }
+}
diff --git a/backend/plugins/kiro/models/s3_file_meta.go 
b/backend/plugins/kiro/models/s3_file_meta.go
new file mode 100644
index 000000000..baea17ad4
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_file_meta.go
@@ -0,0 +1,75 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// File types, derived from the S3 prefix a file was found under. Storing the
+// type lets extractors select their input without re-parsing paths.
+const (
+       FileTypeReport        = "report"
+       FileTypeChatLog       = "chat_log"
+       FileTypeCompletionLog = "completion_log"
+)
+
+// MaxAttempts caps retries of a failing file.
+//
+// Without a cap, a permanently malformed object is retried on every run: the
+// log fills with the same error and the scope never reaches a finished state.
+const MaxAttempts = 3
+
+// KiroS3FileMeta is the incremental collection cursor - one row per S3 object.
+//
+// The primary key is (ConnectionId, S3Path), NOT the file name. Existence
+// checks query by full path, so the key must match: with the path unindexed,
+// each check degenerates into a scan of every row for the connection, and at a
+// few million rows the extractor simply never finishes. The failure mode is 
"the
+// task doesn't complete" rather than an error, which makes it hard to 
diagnose.
+type KiroS3FileMeta struct {
+       common.NoPKModel
+       ConnectionId uint64 `gorm:"primaryKey"`
+       // S3Path is the full object key. 512 chars because real keys already 
run
+       // to ~155 and the prefix is user-configurable.
+       S3Path string `gorm:"primaryKey;type:varchar(512)"`
+       // FileName is the basename, for display and log messages only.
+       FileName string `gorm:"type:varchar(255)" json:"fileName"`
+       // Bucket records which bucket the object came from, since reports and 
logs
+       // may live in different ones.
+       Bucket  string `gorm:"type:varchar(255)" json:"bucket"`
+       ScopeId string `gorm:"type:varchar(255);index" json:"scopeId"`
+       // FileType is one of the FileType* constants above.
+       FileType      string     `gorm:"type:varchar(32);index" json:"fileType"`
+       Processed     bool       `gorm:"default:false" json:"processed"`
+       ProcessedTime *time.Time `gorm:"default:null" json:"processedTime"`
+       // RecordCount is how many rows the file yielded. Together with
+       // ErrorMessage this turns "why is this month short?" from guesswork 
into a
+       // single query.
+       RecordCount int `gorm:"default:0" json:"recordCount"`
+       // ErrorMessage holds the last parse failure reason, if any.
+       ErrorMessage string `gorm:"type:text" json:"errorMessage"`
+       // AttemptCount guards against infinite retries; see MaxAttempts.
+       AttemptCount int `gorm:"default:0" json:"attemptCount"`
+}
+
+func (KiroS3FileMeta) TableName() string {
+       return "_tool_kiro_s3_file_meta"
+}
diff --git a/backend/plugins/kiro/models/s3_slice.go 
b/backend/plugins/kiro/models/s3_slice.go
new file mode 100644
index 000000000..adffb0252
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_slice.go
@@ -0,0 +1,164 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "fmt"
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+       "github.com/apache/incubator-devlake/core/plugin"
+       "gorm.io/gorm"
+)
+
+var _ plugin.ToolLayerScope = (*KiroS3Slice)(nil)
+
+// KiroS3Slice is a collection scope: one AWS account for one month (or one
+// whole year when Month is nil).
+//
+// The granularity mirrors Kiro's own S3 partitioning
+// ({region}/{year}/{month}/{day}/{hour}), so listing a scope touches only the
+// relevant prefixes instead of scanning the bucket. It also means a month can
+// be re-collected in isolation, which is how historical backfill works.
+//
+// Kiro requires a bucket per AWS account holding subscriptions and does not
+// support cross-account buckets, so AccountId belongs in the scope.
+type KiroS3Slice struct {
+       common.Scope `mapstructure:",squash"`
+       // Id is a URL-safe logical identifier: {accountId}_{year}[_{month}]
+       Id string `json:"id" mapstructure:"id" 
gorm:"primaryKey;type:varchar(512)"`
+       // BasePath is an optional extra path segment before AWSLogs/
+       BasePath string `json:"basePath,omitempty" mapstructure:"basePath" 
gorm:"type:varchar(512)"`
+       // AccountId is the AWS account that Kiro exports for
+       AccountId string `json:"accountId" mapstructure:"accountId" 
gorm:"type:varchar(255);not null"`
+       Year      int    `json:"year" mapstructure:"year" gorm:"not null"`
+       // Month is nil to collect a whole year
+       Month *int `json:"month,omitempty" mapstructure:"month"`
+
+       Name string `json:"name" mapstructure:"name" gorm:"-"`
+}
+
+func (KiroS3Slice) TableName() string {
+       return "_tool_kiro_s3_slices"
+}
+
+// BeforeSave derives Id and validates required fields before persisting.
+func (s *KiroS3Slice) BeforeSave(_ *gorm.DB) error {
+       return s.normalize(true)
+}
+
+// AfterFind fills the derived display name for API responses.
+func (s *KiroS3Slice) AfterFind(_ *gorm.DB) error {
+       return s.normalize(false)
+}
+
+// normalize trims inputs and derives Id and Name.
+//
+// Unlike the predecessor scope this deliberately has no path-parsing 
fallback: the
+// scope is always constructed from its component parts, never reverse-derived
+// from a raw prefix string.
+func (s *KiroS3Slice) normalize(strict bool) error {
+       if s == nil {
+               return nil
+       }
+
+       s.BasePath = strings.Trim(strings.TrimSpace(s.BasePath), "/")
+       s.AccountId = strings.TrimSpace(s.AccountId)
+
+       if strict {
+               if s.AccountId == "" {
+                       return fmt.Errorf("accountId is required for a Kiro S3 
slice")
+               }
+               if s.Year <= 0 {
+                       return fmt.Errorf("year is required for a Kiro S3 
slice")
+               }
+       }
+       if s.Month != nil && (*s.Month < 1 || *s.Month > 12) {
+               return fmt.Errorf("month must be between 1 and 12, got %d", 
*s.Month)
+       }
+
+       if s.Id == "" && s.AccountId != "" && s.Year > 0 {
+               s.Id = s.buildId()
+       }
+       s.Name = s.buildName()
+       return nil
+}
+
+func (s *KiroS3Slice) buildId() string {
+       if s.Month != nil {
+               return fmt.Sprintf("%s_%04d_%02d", s.AccountId, s.Year, 
*s.Month)
+       }
+       return fmt.Sprintf("%s_%04d", s.AccountId, s.Year)
+}
+
+func (s *KiroS3Slice) buildName() string {
+       if s.AccountId == "" || s.Year <= 0 {
+               return s.Id
+       }
+       if s.Month != nil {
+               return fmt.Sprintf("%s %04d-%02d", s.AccountId, s.Year, 
*s.Month)
+       }
+       return fmt.Sprintf("%s %04d", s.AccountId, s.Year)
+}
+
+// TimePath renders the year/month portion of an S3 prefix. A nil Month yields
+// just the year, which widens collection to the whole year.
+func (s *KiroS3Slice) TimePath() string {
+       if s.Month != nil {
+               return fmt.Sprintf("%04d/%02d", s.Year, *s.Month)
+       }
+       return fmt.Sprintf("%04d", s.Year)
+}
+
+func (s KiroS3Slice) ScopeId() string {
+       return s.Id
+}
+
+func (s KiroS3Slice) ScopeName() string {
+       if s.Name != "" {
+               return s.Name
+       }
+       return s.buildName()
+}
+
+func (s KiroS3Slice) ScopeFullName() string {
+       return s.ScopeName()
+}
+
+func (s KiroS3Slice) ScopeParams() interface{} {
+       return &KiroS3SliceParams{
+               ConnectionId: s.ConnectionId,
+               AccountId:    s.AccountId,
+               Year:         s.Year,
+               Month:        s.Month,
+       }
+}
+
+// Sanitize returns a copy ready for JSON serialization.
+func (s KiroS3Slice) Sanitize() KiroS3Slice {
+       _ = s.normalize(false)
+       return s
+}
+
+// KiroS3SliceParams identifies a scope in raw data records.
+type KiroS3SliceParams struct {
+       ConnectionId uint64
+       AccountId    string
+       Year         int
+       Month        *int
+}
diff --git a/backend/plugins/kiro/models/s3_slice_test.go 
b/backend/plugins/kiro/models/s3_slice_test.go
new file mode 100644
index 000000000..c200cc2f6
--- /dev/null
+++ b/backend/plugins/kiro/models/s3_slice_test.go
@@ -0,0 +1,110 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func intPtr(i int) *int { return &i }
+
+func TestS3SliceNormalize(t *testing.T) {
+       t.Run("month scope derives id and name", func(t *testing.T) {
+               s := &KiroS3Slice{AccountId: "123456789012", Year: 2026, Month: 
intPtr(7)}
+               assert.NoError(t, s.normalize(true))
+               assert.Equal(t, "123456789012_2026_07", s.Id)
+               assert.Equal(t, "123456789012 2026-07", s.Name)
+               assert.Equal(t, "2026/07", s.TimePath())
+       })
+
+       t.Run("year scope widens to the whole year", func(t *testing.T) {
+               s := &KiroS3Slice{AccountId: "123456789012", Year: 2026}
+               assert.NoError(t, s.normalize(true))
+               assert.Equal(t, "123456789012_2026", s.Id)
+               assert.Equal(t, "2026", s.TimePath())
+       })
+
+       t.Run("single digit month is zero padded", func(t *testing.T) {
+               s := &KiroS3Slice{AccountId: "acct", Year: 2026, Month: 
intPtr(3)}
+               assert.NoError(t, s.normalize(true))
+               // Padding matters: Kiro's own S3 partitions are two-digit, so 
an
+               // unpadded value would build a prefix that matches nothing.
+               assert.Equal(t, "acct_2026_03", s.Id)
+               assert.Equal(t, "2026/03", s.TimePath())
+       })
+
+       t.Run("explicit id is preserved", func(t *testing.T) {
+               s := &KiroS3Slice{Id: "custom-id", AccountId: "acct", Year: 
2026}
+               assert.NoError(t, s.normalize(true))
+               assert.Equal(t, "custom-id", s.Id)
+       })
+
+       t.Run("base path is trimmed", func(t *testing.T) {
+               s := &KiroS3Slice{BasePath: " /nested/path/ ", AccountId: 
"acct", Year: 2026}
+               assert.NoError(t, s.normalize(true))
+               assert.Equal(t, "nested/path", s.BasePath)
+       })
+}
+
+func TestS3SliceValidation(t *testing.T) {
+       t.Run("account id required in strict mode", func(t *testing.T) {
+               s := &KiroS3Slice{Year: 2026}
+               assert.Error(t, s.normalize(true))
+       })
+
+       t.Run("year required in strict mode", func(t *testing.T) {
+               s := &KiroS3Slice{AccountId: "acct"}
+               assert.Error(t, s.normalize(true))
+       })
+
+       // Non-strict mode runs on read, where rejecting an existing row would 
make
+       // it unreadable rather than merely incomplete.
+       t.Run("missing fields tolerated in non-strict mode", func(t *testing.T) 
{
+               s := &KiroS3Slice{}
+               assert.NoError(t, s.normalize(false))
+       })
+
+       for _, month := range []int{0, 13, -1} {
+               t.Run("invalid month rejected in both modes", func(t 
*testing.T) {
+                       s := &KiroS3Slice{AccountId: "acct", Year: 2026, Month: 
intPtr(month)}
+                       assert.Error(t, s.normalize(true))
+                       // An out-of-range month cannot produce a valid prefix, 
so it is
+                       // rejected on read too rather than silently building a 
bad path.
+                       s2 := &KiroS3Slice{AccountId: "acct", Year: 2026, 
Month: intPtr(month)}
+                       assert.Error(t, s2.normalize(false))
+               })
+       }
+}
+
+func TestS3SliceScopeInterface(t *testing.T) {
+       s := KiroS3Slice{AccountId: "123456789012", Year: 2026, Month: 
intPtr(7)}
+       assert.NoError(t, s.normalize(true))
+
+       assert.Equal(t, "123456789012_2026_07", s.ScopeId())
+       assert.Equal(t, "123456789012 2026-07", s.ScopeName())
+       assert.Equal(t, "123456789012 2026-07", s.ScopeFullName())
+       assert.Equal(t, "_tool_kiro_s3_slices", s.TableName())
+
+       params, ok := s.ScopeParams().(*KiroS3SliceParams)
+       assert.True(t, ok)
+       assert.Equal(t, "123456789012", params.AccountId)
+       assert.Equal(t, 2026, params.Year)
+       assert.Equal(t, 7, *params.Month)
+}
diff --git a/backend/plugins/kiro/models/user_model_messages.go 
b/backend/plugins/kiro/models/user_model_messages.go
new file mode 100644
index 000000000..89268b44c
--- /dev/null
+++ b/backend/plugins/kiro/models/user_model_messages.go
@@ -0,0 +1,57 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// KiroUserModelMessage holds the per-model message counts from the report CSV.
+//
+// These arrive as dynamic columns ("claude_opus_4.6_messages") whose names and
+// count vary with the models a team uses, so they cannot live in a fixed 
struct.
+// A narrow table also means a new model appears in the data without a 
migration.
+//
+// This table - not KiroChatLog.ModelId - is the authoritative source for model
+// attribution: the interaction log carries ModelId on only 45% of records, so
+// any share-of-usage computed from it is systematically skewed.
+type KiroUserModelMessage struct {
+       common.NoPKModel
+       ConnectionId uint64    `gorm:"primaryKey"`
+       ScopeId      string    `gorm:"primaryKey;type:varchar(255)"`
+       UserId       string    `gorm:"primaryKey;type:varchar(64)"`
+       Date         time.Time `gorm:"primaryKey;type:date"`
+       ClientType   string    `gorm:"primaryKey;type:varchar(20)"`
+       // ModelName keeps the CSV's underscore spelling ("claude_opus_4.6"). 
The
+       // interaction log spells the same model with hyphens; values are 
stored as
+       // each source emits them and reconciled at query time, so the stored 
value
+       // always traces back to its source.
+       //
+       // Note that some values are routing modes rather than models ("auto",
+       // "simple_task"). Upstream presents them through the same column 
pattern,
+       // so no distinction is invented here.
+       ModelName string `gorm:"primaryKey;type:varchar(100)"`
+
+       MessageCount int `json:"messageCount"`
+}
+
+func (KiroUserModelMessage) TableName() string {
+       return "_tool_kiro_user_model_messages"
+}
diff --git a/backend/plugins/kiro/models/user_report.go 
b/backend/plugins/kiro/models/user_report.go
new file mode 100644
index 000000000..8f5675508
--- /dev/null
+++ b/backend/plugins/kiro/models/user_report.go
@@ -0,0 +1,78 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+       "time"
+
+       "github.com/apache/incubator-devlake/core/models/common"
+)
+
+// KiroUserReport is one row of the daily per-user activity report.
+//
+// Kiro writes one CSV per client type per day, so the same person appears once
+// per client type they used - ClientType is therefore part of the key, not a
+// descriptive column.
+//
+// Several fields are nullable because the report schema has grown over time:
+// across the sampled history there are 19 distinct header layouts, and
+// User_Email and New_User were both introduced partway through. Rows collected
+// from earlier months legitimately lack them.
+type KiroUserReport struct {
+       common.NoPKModel
+       ConnectionId uint64 `gorm:"primaryKey"`
+       ScopeId      string `gorm:"primaryKey;type:varchar(255)"`
+       // UserId is the bare UUID with any identity-store prefix stripped. Logs
+       // always carry the prefix and some report files do too, so both paths
+       // normalize - otherwise one person yields two distinct ids and every
+       // per-person aggregate is quietly wrong.
+       UserId string    `gorm:"primaryKey;type:varchar(64)"`
+       Date   time.Time `gorm:"primaryKey;type:date"`
+       // ClientType is KIRO_IDE, KIRO_CLI, KIRO_WEB or PLUGIN. KIRO_WEB 
appears in
+       // real exports but not in the published docs, so this is not validated
+       // against a fixed set.
+       ClientType string `gorm:"primaryKey;type:varchar(20)"`
+
+       // IdentityStoreId is set only when the source value carried a prefix.
+       IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"`
+       // UserEmail is the join key to git identity. Nullable: absent in early
+       // report versions.
+       UserEmail *string `gorm:"type:varchar(255);index" json:"userEmail"`
+       // DisplayName comes from IAM Identity Center and is for display only.
+       DisplayName *string `gorm:"type:varchar(255)" json:"displayName"`
+       // ProfileId is a full ARN, not a short id.
+       ProfileId string `gorm:"type:varchar(512)" json:"profileId"`
+       // SubscriptionTier is normalized to UPPER_SNAKE (POWER, PRO_PLUS).
+       SubscriptionTier string `gorm:"type:varchar(50)" 
json:"subscriptionTier"`
+       // IsNewUser marks a subscription activated on the report date, which 
gives
+       // an adoption timestamp directly instead of inferring one from first 
usage.
+       // Nullable: absent in early report versions.
+       IsNewUser *bool `json:"isNewUser"`
+
+       ChatConversations int     `json:"chatConversations"`
+       TotalMessages     int     `json:"totalMessages"`
+       CreditsUsed       float64 `json:"creditsUsed"`
+       OverageCap        float64 `json:"overageCap"`
+       // OverageCreditsUsed is genuinely non-zero in real data.
+       OverageCreditsUsed float64 `json:"overageCreditsUsed"`
+       OverageEnabled     bool    `json:"overageEnabled"`
+}
+
+func (KiroUserReport) TableName() string {
+       return "_tool_kiro_user_report"
+}
diff --git a/backend/plugins/kiro/tasks/normalize.go 
b/backend/plugins/kiro/tasks/normalize.go
new file mode 100644
index 000000000..89350b993
--- /dev/null
+++ b/backend/plugins/kiro/tasks/normalize.go
@@ -0,0 +1,171 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+       "regexp"
+       "strings"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/errors"
+)
+
+// Kiro exports the same logical value in several shapes across its two data
+// sources. Every normalization helper here exists because a real sample proved
+// the variation exists; see the design spec section 3 for the evidence.
+
+// modelColumnRe matches the per-model message count columns in the user report
+// CSV, e.g. "claude_opus_4.6_messages". The column set is dynamic - it grows
+// and shrinks with the models a team actually uses.
+var modelColumnRe = regexp.MustCompile(`(?i)^(.+)_messages$`)
+
+// totalMessagesColumn is the aggregate column that must never be mistaken for 
a
+// model. It matches modelColumnRe, and its value happens to equal the sum of
+// all model columns, so treating it as a model yields a plausible-looking but
+// wrong distribution.
+const totalMessagesColumn = "total_messages"
+
+// SplitUserId separates an identity store prefixed user id into its parts.
+//
+// Interaction logs always carry the prefix ("d-1234567890.11111111-..."), 
while
+// report CSVs usually do not - but three sampled report files did. Both paths
+// must normalize, otherwise the same person lands in the table twice and every
+// per-person aggregate is silently wrong.
+//
+// Returns the bare user id, plus the identity store id when a prefix was
+// present (empty string otherwise).
+func SplitUserId(raw string) (userId string, identityStoreId string) {
+       trimmed := strings.TrimSpace(raw)
+       if !strings.HasPrefix(trimmed, "d-") {
+               return trimmed, ""
+       }
+       // Only the first separator matters: the identity store id itself never
+       // contains a dot, while the trailing UUID may not be validated here.
+       idx := strings.Index(trimmed, ".")
+       if idx < 0 {
+               // "d-something" with no separator is not a prefixed id.
+               return trimmed, ""
+       }
+       prefix := trimmed[:idx]
+       rest := trimmed[idx+1:]
+       if rest == "" {
+               // A trailing dot with nothing after it carries no user id; 
keep the
+               // original value rather than inventing an empty one.
+               return trimmed, ""
+       }
+       return rest, prefix
+}
+
+// ParseKiroBool parses a boolean whose casing drifts between report versions:
+// March samples emit "TRUE", July samples emit "true".
+func ParseKiroBool(s string) (bool, errors.Error) {
+       switch strings.ToLower(strings.TrimSpace(s)) {
+       case "true":
+               return true, nil
+       case "false":
+               return false, nil
+       default:
+               return false, errors.Default.New("unrecognized boolean value: " 
+ s)
+       }
+}
+
+// NormalizeTier canonicalizes a subscription tier to UPPER_SNAKE.
+//
+// Observed values are already UPPER_SNAKE ("POWER", "PRO_PLUS") while the
+// official docs write them in CamelCase ("Power", "ProPlus"). Unknown tiers 
are
+// upper-cased and returned as-is rather than rejected - a new tier must not
+// break collection.
+func NormalizeTier(s string) string {
+       trimmed := strings.TrimSpace(s)
+       if trimmed == "" {
+               return ""
+       }
+       // Insert a separator at lower-to-upper transitions so "ProPlus" becomes
+       // "PRO_PLUS", while an already-snake "PRO_PLUS" is left alone.
+       var b strings.Builder
+       runes := []rune(trimmed)
+       for i, r := range runes {
+               if i > 0 && r >= 'A' && r <= 'Z' {
+                       prev := runes[i-1]
+                       if prev >= 'a' && prev <= 'z' {
+                               b.WriteRune('_')
+                       }
+               }
+               b.WriteRune(r)
+       }
+       return strings.ToUpper(b.String())
+}
+
+// ParseModelColumn reports whether a CSV header is a per-model message count
+// column, returning the model name when it is.
+//
+// "Total_Messages" is excluded explicitly: it matches the same pattern but is
+// the aggregate, not a model.
+func ParseModelColumn(header string) (string, bool) {
+       trimmed := strings.TrimSpace(header)
+       if strings.EqualFold(trimmed, totalMessagesColumn) {
+               return "", false
+       }
+       m := modelColumnRe.FindStringSubmatch(trimmed)
+       if m == nil {
+               return "", false
+       }
+       name := m[1]
+       if name == "" {
+               return "", false
+       }
+       return name, true
+}
+
+// CanonicalModelName folds the two spellings of a model identifier onto one
+// form so they can be compared.
+//
+// The report CSV uses underscores ("claude_opus_4.6") while interaction logs
+// use hyphens ("claude-opus-4.6"). Values are stored as the source emits them;
+// this helper is for comparison only.
+func CanonicalModelName(s string) string {
+       return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), "-", 
"_"))
+}
+
+// ParseKiroTime parses an interaction log timestamp.
+//
+// Log timestamps carry nanosecond precision ("2026-07-27T23:03:29.027400929Z")
+// but MySQL DATETIME(6) only stores microseconds, so the value is truncated
+// here rather than at the storage layer - that keeps the truncation explicit
+// and identical on every database backend.
+func ParseKiroTime(s string) (time.Time, errors.Error) {
+       trimmed := strings.TrimSpace(s)
+       t, err := time.Parse(time.RFC3339Nano, trimmed)
+       if err != nil {
+               return time.Time{}, errors.Default.Wrap(err, "failed to parse 
kiro timestamp: "+trimmed)
+       }
+       return t.Truncate(time.Microsecond), nil
+}
+
+// ParseReportDate parses a user report date, which is strictly ISO 8601.
+//
+// Deliberately strict: a lenient parser would accept the legacy
+// by_user_analytic format (MM-DD-YYYY) and silently produce a wrong date.
+func ParseReportDate(s string) (time.Time, errors.Error) {
+       trimmed := strings.TrimSpace(s)
+       t, err := time.Parse(time.DateOnly, trimmed)
+       if err != nil {
+               return time.Time{}, errors.Default.Wrap(err, "failed to parse 
kiro report date: "+trimmed)
+       }
+       return t, nil
+}
diff --git a/backend/plugins/kiro/tasks/normalize_test.go 
b/backend/plugins/kiro/tasks/normalize_test.go
new file mode 100644
index 000000000..d00cce892
--- /dev/null
+++ b/backend/plugins/kiro/tasks/normalize_test.go
@@ -0,0 +1,273 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestSplitUserId(t *testing.T) {
+       tests := []struct {
+               name            string
+               raw             string
+               wantUserId      string
+               wantIdentityStr string
+       }{
+               {
+                       // Every interaction log record carries this shape.
+                       name:            "prefixed as seen in interaction logs",
+                       raw:             
"d-1234567890.11111111-1111-4111-8111-111111111111",
+                       wantUserId:      "11111111-1111-4111-8111-111111111111",
+                       wantIdentityStr: "d-1234567890",
+               },
+               {
+                       // The common report CSV shape.
+                       name:            "bare uuid passes through",
+                       raw:             "11111111-1111-4111-8111-111111111111",
+                       wantUserId:      "11111111-1111-4111-8111-111111111111",
+                       wantIdentityStr: "",
+               },
+               {
+                       name:            "empty",
+                       raw:             "",
+                       wantUserId:      "",
+                       wantIdentityStr: "",
+               },
+               {
+                       // A uuid containing dots but no d- prefix must not be 
split.
+                       name:            "no d prefix but has dot",
+                       raw:             "abc.def",
+                       wantUserId:      "abc.def",
+                       wantIdentityStr: "",
+               },
+               {
+                       name:            "d prefix without separator is not a 
prefixed id",
+                       raw:             "d-1234567890",
+                       wantUserId:      "d-1234567890",
+                       wantIdentityStr: "",
+               },
+               {
+                       name:            "trailing dot carries no user id",
+                       raw:             "d-1234567890.",
+                       wantUserId:      "d-1234567890.",
+                       wantIdentityStr: "",
+               },
+               {
+                       // Only the first separator splits; the remainder is 
the user id
+                       // verbatim even if it contains further dots.
+                       name:            "multiple dots split on the first",
+                       raw:             "d-abc.part1.part2",
+                       wantUserId:      "part1.part2",
+                       wantIdentityStr: "d-abc",
+               },
+               {
+                       name:            "surrounding whitespace trimmed",
+                       raw:             "  d-1234567890.11111111  ",
+                       wantUserId:      "11111111",
+                       wantIdentityStr: "d-1234567890",
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotUserId, gotIdentityStore := SplitUserId(tt.raw)
+                       assert.Equal(t, tt.wantUserId, gotUserId)
+                       assert.Equal(t, tt.wantIdentityStr, gotIdentityStore)
+               })
+       }
+}
+
+func TestParseKiroBool(t *testing.T) {
+       trueCases := []string{"true", "TRUE", "True", " true "}
+       for _, c := range trueCases {
+               t.Run("true/"+c, func(t *testing.T) {
+                       got, err := ParseKiroBool(c)
+                       assert.Nil(t, err)
+                       assert.True(t, got)
+               })
+       }
+
+       falseCases := []string{"false", "FALSE", "False"}
+       for _, c := range falseCases {
+               t.Run("false/"+c, func(t *testing.T) {
+                       got, err := ParseKiroBool(c)
+                       assert.Nil(t, err)
+                       assert.False(t, got)
+               })
+       }
+
+       // An unrecognized value must surface as an error rather than default to
+       // false - a silent false would be indistinguishable from real data.
+       errCases := []string{"", "yes", "1", "0", "null"}
+       for _, c := range errCases {
+               t.Run("error/"+c, func(t *testing.T) {
+                       _, err := ParseKiroBool(c)
+                       assert.NotNil(t, err)
+               })
+       }
+}
+
+func TestNormalizeTier(t *testing.T) {
+       tests := []struct {
+               in   string
+               want string
+       }{
+               // Observed values are already canonical.
+               {"POWER", "POWER"},
+               {"PRO_PLUS", "PRO_PLUS"},
+               // The docs spell them in CamelCase.
+               {"Power", "POWER"},
+               {"ProPlus", "PRO_PLUS"},
+               {"ProMax", "PRO_MAX"},
+               {"Pro", "PRO"},
+               // Unknown tiers must pass through rather than break collection.
+               {"SomeFutureTier", "SOME_FUTURE_TIER"},
+               {"power", "POWER"},
+               {"", ""},
+               {"  POWER  ", "POWER"},
+       }
+       for _, tt := range tests {
+               t.Run(tt.in, func(t *testing.T) {
+                       assert.Equal(t, tt.want, NormalizeTier(tt.in))
+               })
+       }
+}
+
+func TestParseModelColumn(t *testing.T) {
+       // All eight model/mode columns observed across the full report history.
+       modelColumns := map[string]string{
+               "claude_opus_4.6_messages":   "claude_opus_4.6",
+               "claude_opus_4.7_messages":   "claude_opus_4.7",
+               "claude_opus_4.8_messages":   "claude_opus_4.8",
+               "claude_opus_5_messages":     "claude_opus_5",
+               "claude_sonnet_4.6_messages": "claude_sonnet_4.6",
+               "gpt_5.6_sol_messages":       "gpt_5.6_sol",
+               "auto_messages":              "auto",
+               "simple_task_messages":       "simple_task",
+       }
+       for header, want := range modelColumns {
+               t.Run("model/"+header, func(t *testing.T) {
+                       got, ok := ParseModelColumn(header)
+                       assert.True(t, ok)
+                       assert.Equal(t, want, got)
+               })
+       }
+
+       // Total_Messages matches the pattern but is the aggregate. If it were
+       // treated as a model named "Total", its value would equal the sum of 
all
+       // real models - a wrong result that looks entirely plausible 
downstream.
+       // The exclusion is case-insensitive because the pattern is.
+       notModels := []string{
+               "Total_Messages",
+               "total_messages",
+               "TOTAL_MESSAGES",
+               " Total_Messages ",
+               "Chat_Conversations",
+               "Credits_Used",
+               "User_Email",
+               "_messages",
+               "messages",
+               "",
+       }
+       for _, header := range notModels {
+               t.Run("not_model/"+header, func(t *testing.T) {
+                       got, ok := ParseModelColumn(header)
+                       assert.False(t, ok, "header %q must not be treated as a 
model", header)
+                       assert.Equal(t, "", got)
+               })
+       }
+}
+
+func TestCanonicalModelName(t *testing.T) {
+       // The two sources spell the same model differently; canonical form lets
+       // them be compared without rewriting either source's stored value.
+       assert.Equal(t,
+               CanonicalModelName("claude-opus-4.6"),
+               CanonicalModelName("claude_opus_4.6"),
+       )
+       assert.Equal(t,
+               CanonicalModelName("simple-task"),
+               CanonicalModelName("simple_task"),
+       )
+       // Distinct models must stay distinct.
+       assert.NotEqual(t,
+               CanonicalModelName("claude-opus-4.6"),
+               CanonicalModelName("claude-opus-4.8"),
+       )
+       assert.Equal(t, "claude_opus_4.6", 
CanonicalModelName("Claude-Opus-4.6"))
+}
+
+func TestParseKiroTime(t *testing.T) {
+       // The real shape emitted by interaction logs.
+       t.Run("nanosecond precision truncates to microsecond", func(t 
*testing.T) {
+               got, err := ParseKiroTime("2026-07-27T23:03:29.027400929Z")
+               assert.Nil(t, err)
+               want := time.Date(2026, 7, 27, 23, 3, 29, 27400000, time.UTC)
+               assert.True(t, got.Equal(want), "got %v want %v", got, want)
+               // Nanosecond remainder beyond microsecond precision must be 
gone, so
+               // the value round-trips through DATETIME(6) unchanged.
+               assert.Equal(t, 0, got.Nanosecond()%1000)
+       })
+
+       t.Run("microsecond precision preserved", func(t *testing.T) {
+               got, err := ParseKiroTime("2026-07-27T23:03:29.027400Z")
+               assert.Nil(t, err)
+               assert.Equal(t, 27400000, got.Nanosecond())
+       })
+
+       t.Run("no fractional seconds", func(t *testing.T) {
+               got, err := ParseKiroTime("2026-07-27T23:03:29Z")
+               assert.Nil(t, err)
+               assert.Equal(t, 0, got.Nanosecond())
+               assert.Equal(t, 29, got.Second())
+       })
+
+       for _, bad := range []string{"", "not-a-time", "2026-07-27", 
"07-27-2026"} {
+               t.Run("error/"+bad, func(t *testing.T) {
+                       _, err := ParseKiroTime(bad)
+                       assert.NotNil(t, err)
+               })
+       }
+}
+
+func TestParseReportDate(t *testing.T) {
+       t.Run("iso date", func(t *testing.T) {
+               got, err := ParseReportDate("2026-07-27")
+               assert.Nil(t, err)
+               assert.Equal(t, 2026, got.Year())
+               assert.Equal(t, time.July, got.Month())
+               assert.Equal(t, 27, got.Day())
+       })
+
+       // The legacy by_user_analytic report used MM-DD-YYYY. That report is 
out of
+       // scope, but a lenient parser would accept its format here and produce 
a
+       // wrong date without any error - so rejection is asserted explicitly.
+       t.Run("legacy MM-DD-YYYY rejected", func(t *testing.T) {
+               _, err := ParseReportDate("07-27-2026")
+               assert.NotNil(t, err)
+       })
+
+       for _, bad := range []string{"", "2026/07/27", "2026-13-45", 
"not-a-date"} {
+               t.Run("error/"+bad, func(t *testing.T) {
+                       _, err := ParseReportDate(bad)
+                       assert.NotNil(t, err)
+               })
+       }
+}
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
 
b/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
new file mode 100644
index 000000000..44b1f3b2e
--- /dev/null
+++ 
b/backend/plugins/kiro/tasks/testdata/user_report/01_earliest_no_email_no_newuser.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages
+2026-03-05,"11111111-1111-4111-8111-111111111111",KIRO_CLI,4,6.421250216662884,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,28
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv 
b/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv
new file mode 100644
index 000000000..f14255cf9
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/02_standard_cli.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_5_messages
+2026-07-27,"11111111-1111-4111-8111-111111111111",KIRO_CLI,39,308.97581546497514,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,618,false,"[email protected]",618
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv 
b/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv
new file mode 100644
index 000000000..2f4920cb2
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/03_standard_ide.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_5_messages
+2026-07-27,"11111111-1111-4111-8111-111111111111",KIRO_IDE,2,19.59644039641791,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,30,false,"[email protected]",30
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv 
b/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv
new file mode 100644
index 000000000..8a2be22b8
--- /dev/null
+++ 
b/backend/plugins/kiro/tasks/testdata/user_report/04_newuser_after_models.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,claude_opus_4.6_messages,claude_opus_4.7_messages,New_User
+2026-05-20,"11111111-1111-4111-8111-111111111111",KIRO_CLI,4,1155.1243810902488,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,612,550,62,false
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
 
b/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
new file mode 100644
index 000000000..0e3744b51
--- /dev/null
+++ 
b/backend/plugins/kiro/tasks/testdata/user_report/05_prefixed_userid_upper_bool.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,claude_opus_4.6_messages,simple_task_messages
+2026-03-10,d-1234567890.11111111-1111-4111-8111-111111111111,KIRO_IDE,3,121.91792578529567,10000.0,0.0,TRUE,arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234,POWER,240,212,28
diff --git a/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv 
b/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv
new file mode 100644
index 000000000..de2fc7c3f
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/06_two_users.csv
@@ -0,0 +1,3 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages
+2026-03-24,"22222222-2222-4222-8222-222222222222",KIRO_IDE,1,2.960539370775223,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,5
+2026-03-24,"11111111-1111-4111-8111-111111111111",KIRO_IDE,4,13.467958366498351,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,17
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
 
b/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
new file mode 100644
index 000000000..430963d80
--- /dev/null
+++ 
b/backend/plugins/kiro/tasks/testdata/user_report/07_kiro_web_nonzero_overage.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,claude_opus_4.8_messages
+2026-06-21,"11111111-1111-4111-8111-111111111111",KIRO_WEB,1,1.281141613432836,10000.0,1.281141613432836,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,1,false,"[email protected]",1
diff --git 
a/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv 
b/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv
new file mode 100644
index 000000000..d2b60a05b
--- /dev/null
+++ b/backend/plugins/kiro/tasks/testdata/user_report/08_plugin_multi_model.csv
@@ -0,0 +1,2 @@
+Date,UserId,Client_Type,Chat_Conversations,Credits_Used,Overage_Cap,Overage_Credits_Used,Overage_Enabled,ProfileId,Subscription_Tier,Total_Messages,New_User,User_Email,auto_messages,claude_sonnet_4.6_messages
+2026-06-07,"11111111-1111-4111-8111-111111111111",PLUGIN,1,1.6401393711566925,10000.0,0.0,true,"arn:aws:codewhisperer:us-east-1:123456789012:profile/AKEXAMPLE1234",POWER,16,false,"[email protected]",9,7
diff --git a/backend/plugins/kiro/tasks/user_report_parser.go 
b/backend/plugins/kiro/tasks/user_report_parser.go
new file mode 100644
index 000000000..7909b31cf
--- /dev/null
+++ b/backend/plugins/kiro/tasks/user_report_parser.go
@@ -0,0 +1,294 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+       "bytes"
+       "encoding/csv"
+       "io"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/plugins/kiro/models"
+)
+
+// Known column names in the user activity report. Only the first eleven are
+// stable; everything after Total_Messages varies by report version.
+const (
+       colDate               = "Date"
+       colUserId             = "UserId"
+       colClientType         = "Client_Type"
+       colChatConversations  = "Chat_Conversations"
+       colCreditsUsed        = "Credits_Used"
+       colOverageCap         = "Overage_Cap"
+       colOverageCreditsUsed = "Overage_Credits_Used"
+       colOverageEnabled     = "Overage_Enabled"
+       colProfileId          = "ProfileId"
+       colSubscriptionTier   = "Subscription_Tier"
+       colTotalMessages      = "Total_Messages"
+       colNewUser            = "New_User"
+       colUserEmail          = "User_Email"
+)
+
+// ParseUserReport parses a user activity report CSV into report rows and their
+// per-model message counts.
+//
+// The parser is deliberately tolerant of schema drift. Across the sampled
+// history the report has had 19 distinct header layouts: columns were added
+// over time, their order moved, and the per-model columns come and go with the
+// models a team uses. So:
+//
+//   - every field is located by column name, never by position
+//   - unknown columns are ignored rather than treated as an error
+//   - columns that are absent yield NULL, not a zero value
+//
+// It is a pure function: no S3, no database, no task context. That keeps it
+// testable against real sample bytes.
+func ParseUserReport(data []byte, connectionId uint64, scopeId string) 
([]*models.KiroUserReport, []*models.KiroUserModelMessage, errors.Error) {
+       reader := csv.NewReader(bytes.NewReader(data))
+       // Row width varies across report versions, so do not enforce a fixed 
count.
+       reader.FieldsPerRecord = -1
+
+       headers, err := reader.Read()
+       if err != nil {
+               if err == io.EOF {
+                       // An empty file is not a failure; it simply has no 
rows.
+                       return nil, nil, nil
+               }
+               return nil, nil, errors.Default.Wrap(err, "failed to read user 
report CSV header")
+       }
+       for i := range headers {
+               headers[i] = strings.TrimSpace(headers[i])
+       }
+
+       var reports []*models.KiroUserReport
+       var modelMessages []*models.KiroUserModelMessage
+
+       for {
+               record, readErr := reader.Read()
+               if readErr == io.EOF {
+                       break
+               }
+               if readErr != nil {
+                       return nil, nil, errors.Default.Wrap(readErr, "failed 
to read user report CSV row")
+               }
+               if isBlankRecord(record) {
+                       continue
+               }
+
+               fields := zipHeaders(headers, record)
+
+               report, rowModels, rowErr := buildReportRow(fields, 
connectionId, scopeId)
+               if rowErr != nil {
+                       return nil, nil, rowErr
+               }
+               reports = append(reports, report)
+               modelMessages = append(modelMessages, rowModels...)
+       }
+
+       return reports, modelMessages, nil
+}
+
+// buildReportRow converts one name-keyed CSV row into its models.
+func buildReportRow(fields map[string]string, connectionId uint64, scopeId 
string) (*models.KiroUserReport, []*models.KiroUserModelMessage, errors.Error) {
+       date, err := ParseReportDate(fields[colDate])
+       if err != nil {
+               return nil, nil, err
+       }
+
+       // Report CSVs usually carry a bare UUID, but some files carry the
+       // identity-store prefix. Normalizing both paths keeps one person from
+       // splitting into two ids.
+       userId, identityStoreId := SplitUserId(fields[colUserId])
+       clientType := strings.TrimSpace(fields[colClientType])
+
+       report := &models.KiroUserReport{
+               ConnectionId:    connectionId,
+               ScopeId:         scopeId,
+               UserId:          userId,
+               Date:            date,
+               ClientType:      clientType,
+               IdentityStoreId: identityStoreId,
+               ProfileId:       strings.TrimSpace(fields[colProfileId]),
+               // Tiers arrive as UPPER_SNAKE in real data but CamelCase in 
the docs;
+               // normalizing means an unrecognized tier still stores cleanly.
+               SubscriptionTier: NormalizeTier(fields[colSubscriptionTier]),
+       }
+
+       // These two columns were introduced partway through the report's 
history.
+       // When absent they must stay NULL: a zero value would be 
indistinguishable
+       // from real data, and an empty email would look like a failed identity 
join
+       // rather than an unavailable one.
+       if raw, ok := fields[colUserEmail]; ok {
+               if email := strings.TrimSpace(raw); email != "" {
+                       report.UserEmail = &email
+               }
+       }
+       if raw, ok := fields[colNewUser]; ok {
+               if trimmed := strings.TrimSpace(raw); trimmed != "" {
+                       isNew, boolErr := ParseKiroBool(trimmed)
+                       if boolErr != nil {
+                               return nil, nil, boolErr
+                       }
+                       report.IsNewUser = &isNew
+               }
+       }
+
+       var intErr errors.Error
+       if report.ChatConversations, intErr = optionalInt(fields, 
colChatConversations); intErr != nil {
+               return nil, nil, intErr
+       }
+       if report.TotalMessages, intErr = optionalInt(fields, 
colTotalMessages); intErr != nil {
+               return nil, nil, intErr
+       }
+
+       var floatErr errors.Error
+       if report.CreditsUsed, floatErr = optionalFloat(fields, 
colCreditsUsed); floatErr != nil {
+               return nil, nil, floatErr
+       }
+       if report.OverageCap, floatErr = optionalFloat(fields, colOverageCap); 
floatErr != nil {
+               return nil, nil, floatErr
+       }
+       // Genuinely non-zero in real data, so it is parsed rather than assumed.
+       if report.OverageCreditsUsed, floatErr = optionalFloat(fields, 
colOverageCreditsUsed); floatErr != nil {
+               return nil, nil, floatErr
+       }
+
+       if raw, ok := fields[colOverageEnabled]; ok {
+               if trimmed := strings.TrimSpace(raw); trimmed != "" {
+                       // Casing drifts between report versions (TRUE vs true).
+                       enabled, boolErr := ParseKiroBool(trimmed)
+                       if boolErr != nil {
+                               return nil, nil, boolErr
+                       }
+                       report.OverageEnabled = enabled
+               }
+       }
+
+       modelMessages, modelErr := buildModelMessages(fields, connectionId, 
scopeId, userId, date, clientType)
+       if modelErr != nil {
+               return nil, nil, modelErr
+       }
+
+       return report, modelMessages, nil
+}
+
+// buildModelMessages extracts the dynamic per-model message count columns.
+//
+// Total_Messages matches the same naming pattern but is excluded by
+// ParseModelColumn - it is the aggregate, and admitting it would create a
+// phantom model whose count equals the sum of the real ones.
+func buildModelMessages(
+       fields map[string]string,
+       connectionId uint64,
+       scopeId string,
+       userId string,
+       date time.Time,
+       clientType string,
+) ([]*models.KiroUserModelMessage, errors.Error) {
+       var result []*models.KiroUserModelMessage
+       for header, value := range fields {
+               modelName, ok := ParseModelColumn(header)
+               if !ok {
+                       continue
+               }
+               trimmed := strings.TrimSpace(value)
+               if trimmed == "" {
+                       continue
+               }
+               count, err := strconv.Atoi(trimmed)
+               if err != nil {
+                       return nil, errors.Default.Wrap(err, "failed to parse 
message count for model "+modelName)
+               }
+               result = append(result, &models.KiroUserModelMessage{
+                       ConnectionId: connectionId,
+                       ScopeId:      scopeId,
+                       UserId:       userId,
+                       Date:         date,
+                       ClientType:   clientType,
+                       // Stored with the CSV's underscore spelling; 
reconciliation with the
+                       // log's hyphenated form happens at query time.
+                       ModelName:    modelName,
+                       MessageCount: count,
+               })
+       }
+       return result, nil
+}
+
+// zipHeaders pairs header names with row values.
+//
+// Short rows are tolerated: a missing trailing column is simply absent from 
the
+// map, which downstream reads as NULL rather than as a zero value.
+func zipHeaders(headers, record []string) map[string]string {
+       fields := make(map[string]string, len(headers))
+       for i, header := range headers {
+               if i >= len(record) {
+                       break
+               }
+               if header == "" {
+                       continue
+               }
+               fields[header] = record[i]
+       }
+       return fields
+}
+
+func isBlankRecord(record []string) bool {
+       for _, v := range record {
+               if strings.TrimSpace(v) != "" {
+                       return false
+               }
+       }
+       return true
+}
+
+// optionalInt returns 0 when the column is absent or empty. Counters are
+// genuinely zero in that case, unlike the nullable identity columns.
+func optionalInt(fields map[string]string, column string) (int, errors.Error) {
+       raw, ok := fields[column]
+       if !ok {
+               return 0, nil
+       }
+       trimmed := strings.TrimSpace(raw)
+       if trimmed == "" {
+               return 0, nil
+       }
+       v, err := strconv.Atoi(trimmed)
+       if err != nil {
+               return 0, errors.Default.Wrap(err, "failed to parse integer 
column "+column)
+       }
+       return v, nil
+}
+
+func optionalFloat(fields map[string]string, column string) (float64, 
errors.Error) {
+       raw, ok := fields[column]
+       if !ok {
+               return 0, nil
+       }
+       trimmed := strings.TrimSpace(raw)
+       if trimmed == "" {
+               return 0, nil
+       }
+       v, err := strconv.ParseFloat(trimmed, 64)
+       if err != nil {
+               return 0, errors.Default.Wrap(err, "failed to parse float 
column "+column)
+       }
+       return v, nil
+}
diff --git a/backend/plugins/kiro/tasks/user_report_parser_test.go 
b/backend/plugins/kiro/tasks/user_report_parser_test.go
new file mode 100644
index 000000000..112a27023
--- /dev/null
+++ b/backend/plugins/kiro/tasks/user_report_parser_test.go
@@ -0,0 +1,312 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/incubator-devlake/plugins/kiro/models"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+const (
+       testConnectionId = uint64(1)
+       testScopeId      = "123456789012_2026_07"
+)
+
+// Fixtures are real report files with only the user id, email and profile
+// suffix replaced. Every numeric value is untouched, so the assertions below
+// double as a record of what Kiro actually emits.
+func loadReportFixture(t *testing.T, name string) []byte {
+       t.Helper()
+       data, err := os.ReadFile(filepath.Join("testdata", "user_report", name))
+       require.NoError(t, err)
+       return data
+}
+
+func parseReportFixture(t *testing.T, name string) ([]*models.KiroUserReport, 
[]*models.KiroUserModelMessage) {
+       t.Helper()
+       reports, modelMessages, err := ParseUserReport(loadReportFixture(t, 
name), testConnectionId, testScopeId)
+       require.Nil(t, err)
+       return reports, modelMessages
+}
+
+// The earliest report layout has neither User_Email nor New_User nor any model
+// column. Both identity fields must be NULL rather than empty, because a blank
+// email would read as a failed identity join instead of an unavailable one.
+func TestParseUserReport_EarliestVariant(t *testing.T) {
+       reports, modelMessages := parseReportFixture(t, 
"01_earliest_no_email_no_newuser.csv")
+
+       require.Len(t, reports, 1)
+       r := reports[0]
+       assert.Nil(t, r.UserEmail, "User_Email column is absent, so the field 
must be NULL")
+       assert.Nil(t, r.IsNewUser, "New_User column is absent, so the field 
must be NULL")
+       assert.Equal(t, "KIRO_CLI", r.ClientType)
+       assert.Equal(t, "POWER", r.SubscriptionTier)
+       assert.Equal(t, 4, r.ChatConversations)
+       assert.Equal(t, 28, r.TotalMessages)
+       assert.InDelta(t, 6.421250216662884, r.CreditsUsed, 1e-12)
+       assert.Empty(t, modelMessages, "no model columns in this variant")
+}
+
+func TestParseUserReport_StandardCliAndIde(t *testing.T) {
+       // Same person, same day, two client types - which is why ClientType is 
part
+       // of the primary key.
+       cli, cliModels := parseReportFixture(t, "02_standard_cli.csv")
+       require.Len(t, cli, 1)
+       assert.Equal(t, "KIRO_CLI", cli[0].ClientType)
+       assert.Equal(t, 618, cli[0].TotalMessages)
+       assert.Equal(t, 39, cli[0].ChatConversations)
+       assert.InDelta(t, 308.97581546497514, cli[0].CreditsUsed, 1e-12)
+       require.NotNil(t, cli[0].UserEmail)
+       assert.Equal(t, "[email protected]", *cli[0].UserEmail)
+       require.NotNil(t, cli[0].IsNewUser)
+       assert.False(t, *cli[0].IsNewUser)
+       require.Len(t, cliModels, 1)
+       assert.Equal(t, "claude_opus_5", cliModels[0].ModelName)
+       assert.Equal(t, 618, cliModels[0].MessageCount)
+
+       ide, ideModels := parseReportFixture(t, "03_standard_ide.csv")
+       require.Len(t, ide, 1)
+       assert.Equal(t, "KIRO_IDE", ide[0].ClientType)
+       assert.Equal(t, 30, ide[0].TotalMessages)
+       assert.InDelta(t, 19.59644039641791, ide[0].CreditsUsed, 1e-12)
+       require.Len(t, ideModels, 1)
+       assert.Equal(t, 30, ideModels[0].MessageCount)
+
+       // The two rows share a user and date but differ in client type.
+       assert.Equal(t, cli[0].UserId, ide[0].UserId)
+       assert.Equal(t, cli[0].Date, ide[0].Date)
+       assert.NotEqual(t, cli[0].ClientType, ide[0].ClientType)
+}
+
+// In this layout New_User comes after the model columns rather than before
+// them, which is why nothing may be located by position.
+func TestParseUserReport_NewUserAfterModelColumns(t *testing.T) {
+       reports, modelMessages := parseReportFixture(t, 
"04_newuser_after_models.csv")
+
+       require.Len(t, reports, 1)
+       require.NotNil(t, reports[0].IsNewUser)
+       assert.False(t, *reports[0].IsNewUser)
+       assert.Equal(t, 612, reports[0].TotalMessages)
+
+       require.Len(t, modelMessages, 2)
+       counts := map[string]int{}
+       for _, m := range modelMessages {
+               counts[m.ModelName] = m.MessageCount
+       }
+       assert.Equal(t, 550, counts["claude_opus_4.6"])
+       assert.Equal(t, 62, counts["claude_opus_4.7"])
+       // Total_Messages must never surface as a model.
+       assert.NotContains(t, counts, "Total")
+       assert.NotContains(t, counts, "Total_Messages")
+}
+
+// This file carries the identity-store prefix in the CSV's UserId column and
+// spells its boolean in upper case. Without prefix stripping the same person
+// would exist under two ids and every per-person aggregate would be wrong.
+func TestParseUserReport_PrefixedUserIdAndUpperCaseBool(t *testing.T) {
+       reports, modelMessages := parseReportFixture(t, 
"05_prefixed_userid_upper_bool.csv")
+
+       require.Len(t, reports, 1)
+       r := reports[0]
+       assert.Equal(t, "11111111-1111-4111-8111-111111111111", r.UserId,
+               "the d-... prefix must be stripped from the CSV path too")
+       assert.Equal(t, "d-1234567890", r.IdentityStoreId,
+               "the prefix is retained separately for auditability")
+       assert.True(t, r.OverageEnabled, "TRUE in upper case must parse")
+       assert.Equal(t, 240, r.TotalMessages)
+
+       require.Len(t, modelMessages, 2)
+       counts := map[string]int{}
+       for _, m := range modelMessages {
+               counts[m.ModelName] = m.MessageCount
+               // Model rows must carry the normalized id, or they will not 
join to the
+               // report row.
+               assert.Equal(t, r.UserId, m.UserId)
+       }
+       assert.Equal(t, 212, counts["claude_opus_4.6"])
+       // "simple_task" is a routing mode rather than a model, but upstream 
exposes
+       // it through the same column pattern so it is stored the same way.
+       assert.Equal(t, 28, counts["simple_task"])
+}
+
+func TestParseUserReport_MultipleUsers(t *testing.T) {
+       reports, _ := parseReportFixture(t, "06_two_users.csv")
+
+       require.Len(t, reports, 2, "a single file can hold more than one user")
+       ids := []string{reports[0].UserId, reports[1].UserId}
+       assert.NotEqual(t, ids[0], ids[1])
+       for _, r := range reports {
+               assert.Equal(t, "KIRO_IDE", r.ClientType)
+               assert.NotEmpty(t, r.UserId)
+       }
+}
+
+// KIRO_WEB is absent from the published documentation but present in real
+// exports, so client type is never validated against a fixed set. This file
+// also carries a non-zero overage figure.
+func TestParseUserReport_KiroWebAndNonZeroOverage(t *testing.T) {
+       reports, _ := parseReportFixture(t, "07_kiro_web_nonzero_overage.csv")
+
+       require.Len(t, reports, 1)
+       r := reports[0]
+       assert.Equal(t, "KIRO_WEB", r.ClientType)
+       assert.InDelta(t, 1.281141613432836, r.OverageCreditsUsed, 1e-12,
+               "overage credits are genuinely non-zero in real data")
+       assert.InDelta(t, 1.281141613432836, r.CreditsUsed, 1e-12)
+}
+
+func TestParseUserReport_PluginMultiModel(t *testing.T) {
+       reports, modelMessages := parseReportFixture(t, 
"08_plugin_multi_model.csv")
+
+       require.Len(t, reports, 1)
+       assert.Equal(t, "PLUGIN", reports[0].ClientType)
+
+       require.Len(t, modelMessages, 2)
+       counts := map[string]int{}
+       for _, m := range modelMessages {
+               counts[m.ModelName] = m.MessageCount
+       }
+       assert.Equal(t, 9, counts["auto"])
+       assert.Equal(t, 7, counts["claude_sonnet_4.6"])
+}
+
+// Every fixture must satisfy the invariant that the per-model counts add up to
+// Total_Messages. This catches a parser that drops or double-counts a column.
+func TestParseUserReport_ModelCountsSumToTotal(t *testing.T) {
+       fixtures := []string{
+               "02_standard_cli.csv",
+               "03_standard_ide.csv",
+               "04_newuser_after_models.csv",
+               "05_prefixed_userid_upper_bool.csv",
+               "07_kiro_web_nonzero_overage.csv",
+               "08_plugin_multi_model.csv",
+       }
+       for _, name := range fixtures {
+               t.Run(name, func(t *testing.T) {
+                       reports, modelMessages := parseReportFixture(t, name)
+                       require.Len(t, reports, 1)
+
+                       sum := 0
+                       for _, m := range modelMessages {
+                               sum += m.MessageCount
+                       }
+                       assert.Equal(t, reports[0].TotalMessages, sum,
+                               "per-model counts must add up to 
Total_Messages")
+               })
+       }
+}
+
+func TestParseUserReport_EdgeCases(t *testing.T) {
+       t.Run("empty input yields no rows and no error", func(t *testing.T) {
+               reports, modelMessages, err := ParseUserReport(nil, 
testConnectionId, testScopeId)
+               assert.Nil(t, err)
+               assert.Empty(t, reports)
+               assert.Empty(t, modelMessages)
+       })
+
+       t.Run("header only yields no rows", func(t *testing.T) {
+               csvData := []byte("Date,UserId,Client_Type,Total_Messages\n")
+               reports, modelMessages, err := ParseUserReport(csvData, 
testConnectionId, testScopeId)
+               assert.Nil(t, err)
+               assert.Empty(t, reports)
+               assert.Empty(t, modelMessages)
+       })
+
+       t.Run("blank trailing line is skipped", func(t *testing.T) {
+               // Real exports end with a trailing newline, which the CSV 
reader
+               // surfaces as an empty record on some inputs.
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages\n2026-07-27,u1,KIRO_CLI,5\n\n")
+               reports, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.Nil(t, err)
+               assert.Len(t, reports, 1)
+       })
+
+       // A brand new column must not break collection - that is the whole 
point of
+       // name-based mapping.
+       t.Run("unknown columns are ignored", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages,Some_Future_Column\n" +
+                       "2026-07-27,u1,KIRO_CLI,5,whatever\n")
+               reports, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.Nil(t, err)
+               require.Len(t, reports, 1)
+               assert.Equal(t, 5, reports[0].TotalMessages)
+       })
+
+       t.Run("short row leaves trailing columns absent", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages,User_Email\n" +
+                       "2026-07-27,u1,KIRO_CLI,5\n")
+               reports, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.Nil(t, err)
+               require.Len(t, reports, 1)
+               assert.Nil(t, reports[0].UserEmail)
+       })
+
+       t.Run("empty email stays NULL", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages,User_Email\n" +
+                       "2026-07-27,u1,KIRO_CLI,5,\n")
+               reports, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.Nil(t, err)
+               require.Len(t, reports, 1)
+               assert.Nil(t, reports[0].UserEmail)
+       })
+
+       t.Run("zero model count is still recorded", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages,auto_messages\n" +
+                       "2026-07-27,u1,KIRO_CLI,0,0\n")
+               _, modelMessages, err := ParseUserReport(csvData, 
testConnectionId, testScopeId)
+               assert.Nil(t, err)
+               require.Len(t, modelMessages, 1)
+               assert.Equal(t, 0, modelMessages[0].MessageCount)
+       })
+
+       t.Run("bad date is an error", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type\n07-27-2026,u1,KIRO_CLI\n")
+               _, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.NotNil(t, err, "the legacy MM-DD-YYYY format must not 
parse silently")
+       })
+
+       t.Run("bad number is an error", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages\n2026-07-27,u1,KIRO_CLI,not-a-number\n")
+               _, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.NotNil(t, err)
+       })
+
+       t.Run("bad boolean is an error", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Overage_Enabled\n2026-07-27,u1,KIRO_CLI,maybe\n")
+               _, _, err := ParseUserReport(csvData, testConnectionId, 
testScopeId)
+               assert.NotNil(t, err)
+       })
+
+       t.Run("connection and scope are propagated", func(t *testing.T) {
+               csvData := 
[]byte("Date,UserId,Client_Type,Total_Messages,auto_messages\n" +
+                       "2026-07-27,u1,KIRO_CLI,5,5\n")
+               reports, modelMessages, err := ParseUserReport(csvData, 42, 
"scope-x")
+               assert.Nil(t, err)
+               require.Len(t, reports, 1)
+               require.Len(t, modelMessages, 1)
+               assert.Equal(t, uint64(42), reports[0].ConnectionId)
+               assert.Equal(t, "scope-x", reports[0].ScopeId)
+               assert.Equal(t, uint64(42), modelMessages[0].ConnectionId)
+               assert.Equal(t, "scope-x", modelMessages[0].ScopeId)
+       })
+}

Reply via email to