@mmerterden/multi-agent-pipeline 20.0.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +5 -5
- package/README.tr.md +5 -5
- package/SECURITY.md +3 -3
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +8 -7
- package/install/_codex-agents.mjs +1 -1
- package/manifest.json +92 -64
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +2 -2
- package/pipeline/agents/dev-critic.md +5 -5
- package/pipeline/agents/security-auditor.md +80 -72
- package/pipeline/commands/figma-to-swiftui.md +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
- package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
- package/pipeline/multi-agent-refs/threat-model.md +39 -0
- package/pipeline/schemas/agent-state.schema.json +23 -0
- package/pipeline/schemas/phases.json +1 -2
- package/pipeline/schemas/prefs.schema.json +0 -4
- package/pipeline/schemas/reviewer-output.schema.json +99 -2
- package/pipeline/schemas/security-finding.schema.json +144 -0
- package/pipeline/scripts/_stack-routing.mjs +1 -0
- package/pipeline/scripts/gc-abandoned.sh +16 -9
- package/pipeline/scripts/render-work-summary.sh +7 -4
- package/pipeline/skills/.skill-manifest.json +47 -23
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/security-review/SKILL.md +64 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
- package/pipeline/commands/security-review.md +0 -6
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# Room Database Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples backing the guidance in `SKILL.md`. Headings mirror the
|
|
4
|
+
SKILL.md sections.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Entity Definition](#entity-definition)
|
|
9
|
+
- [DAO Interface](#dao-interface)
|
|
10
|
+
- [Database Setup](#database-setup)
|
|
11
|
+
- [Reactive Queries with Flow](#reactive-queries-with-flow)
|
|
12
|
+
- [TypeConverters](#typeconverters)
|
|
13
|
+
- [Relations](#relations)
|
|
14
|
+
- [Migration Strategies](#migration-strategies)
|
|
15
|
+
- [Hilt Integration](#hilt-integration)
|
|
16
|
+
- [Testing with In-Memory Database](#testing-with-in-memory-database)
|
|
17
|
+
- [Encrypted Database (SQLCipher)](#encrypted-database-sqlcipher)
|
|
18
|
+
- [Async Database Provisioning](#async-database-provisioning)
|
|
19
|
+
- [Runtime Hygiene](#runtime-hygiene)
|
|
20
|
+
|
|
21
|
+
## Entity Definition
|
|
22
|
+
|
|
23
|
+
```kotlin
|
|
24
|
+
@Entity(
|
|
25
|
+
tableName = "flights",
|
|
26
|
+
indices = [
|
|
27
|
+
Index(value = ["origin_code", "destination_code"]),
|
|
28
|
+
Index(value = ["departure_time"]),
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
data class FlightEntity(
|
|
32
|
+
@PrimaryKey
|
|
33
|
+
val id: String,
|
|
34
|
+
@ColumnInfo(name = "origin_code")
|
|
35
|
+
val originCode: String,
|
|
36
|
+
@ColumnInfo(name = "origin_name")
|
|
37
|
+
val originName: String,
|
|
38
|
+
@ColumnInfo(name = "destination_code")
|
|
39
|
+
val destinationCode: String,
|
|
40
|
+
@ColumnInfo(name = "destination_name")
|
|
41
|
+
val destinationName: String,
|
|
42
|
+
@ColumnInfo(name = "departure_time")
|
|
43
|
+
val departureTime: String,
|
|
44
|
+
@ColumnInfo(name = "arrival_time")
|
|
45
|
+
val arrivalTime: String,
|
|
46
|
+
@ColumnInfo(name = "price_amount")
|
|
47
|
+
val priceAmount: Double,
|
|
48
|
+
@ColumnInfo(name = "price_currency")
|
|
49
|
+
val priceCurrency: String,
|
|
50
|
+
val status: String,
|
|
51
|
+
@ColumnInfo(name = "updated_at", defaultValue = "0")
|
|
52
|
+
val updatedAt: Long = System.currentTimeMillis(),
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Composite Primary Key
|
|
57
|
+
|
|
58
|
+
```kotlin
|
|
59
|
+
@Entity(
|
|
60
|
+
tableName = "booking_passengers",
|
|
61
|
+
primaryKeys = ["booking_id", "passenger_id"],
|
|
62
|
+
)
|
|
63
|
+
data class BookingPassengerEntity(
|
|
64
|
+
@ColumnInfo(name = "booking_id")
|
|
65
|
+
val bookingId: String,
|
|
66
|
+
@ColumnInfo(name = "passenger_id")
|
|
67
|
+
val passengerId: String,
|
|
68
|
+
@ColumnInfo(name = "seat_number")
|
|
69
|
+
val seatNumber: String?,
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## DAO Interface
|
|
74
|
+
|
|
75
|
+
```kotlin
|
|
76
|
+
@Dao
|
|
77
|
+
interface FlightDao {
|
|
78
|
+
|
|
79
|
+
@Query("SELECT * FROM flights WHERE origin_code = :origin AND destination_code = :destination ORDER BY departure_time ASC")
|
|
80
|
+
fun getFlights(origin: String, destination: String): Flow<List<FlightEntity>>
|
|
81
|
+
|
|
82
|
+
@Query("SELECT * FROM flights WHERE id = :id")
|
|
83
|
+
fun getFlightById(id: String): Flow<FlightEntity?>
|
|
84
|
+
|
|
85
|
+
@Query("SELECT * FROM flights WHERE id = :id")
|
|
86
|
+
suspend fun getFlightByIdOnce(id: String): FlightEntity?
|
|
87
|
+
|
|
88
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
89
|
+
suspend fun insertFlight(flight: FlightEntity)
|
|
90
|
+
|
|
91
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
92
|
+
suspend fun insertFlights(flights: List<FlightEntity>)
|
|
93
|
+
|
|
94
|
+
@Upsert
|
|
95
|
+
suspend fun upsertFlights(flights: List<FlightEntity>)
|
|
96
|
+
|
|
97
|
+
@Update
|
|
98
|
+
suspend fun updateFlight(flight: FlightEntity)
|
|
99
|
+
|
|
100
|
+
@Delete
|
|
101
|
+
suspend fun deleteFlight(flight: FlightEntity)
|
|
102
|
+
|
|
103
|
+
@Query("DELETE FROM flights WHERE id = :id")
|
|
104
|
+
suspend fun deleteFlightById(id: String)
|
|
105
|
+
|
|
106
|
+
@Query("DELETE FROM flights WHERE updated_at < :threshold")
|
|
107
|
+
suspend fun deleteStaleFlights(threshold: Long)
|
|
108
|
+
|
|
109
|
+
@Query("SELECT COUNT(*) FROM flights")
|
|
110
|
+
fun getFlightCount(): Flow<Int>
|
|
111
|
+
|
|
112
|
+
@Transaction
|
|
113
|
+
suspend fun replaceAllFlights(flights: List<FlightEntity>) {
|
|
114
|
+
deleteAllFlights()
|
|
115
|
+
insertFlights(flights)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Query("DELETE FROM flights")
|
|
119
|
+
suspend fun deleteAllFlights()
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### @Upsert vs @Insert(onConflict = REPLACE)
|
|
124
|
+
|
|
125
|
+
| Strategy | Behavior | Use When |
|
|
126
|
+
|----------|----------|----------|
|
|
127
|
+
| `@Upsert` | Insert if absent, update if present (by PK) | Syncing remote data; preserves non-conflicting columns |
|
|
128
|
+
| `@Insert(REPLACE)` | Delete + re-insert on conflict | Simpler; triggers delete cascade |
|
|
129
|
+
| `@Insert(IGNORE)` | Skip on conflict | Inserting if not exists, no update needed |
|
|
130
|
+
|
|
131
|
+
## Database Setup
|
|
132
|
+
|
|
133
|
+
```kotlin
|
|
134
|
+
@Database(
|
|
135
|
+
entities = [
|
|
136
|
+
FlightEntity::class,
|
|
137
|
+
BookingEntity::class,
|
|
138
|
+
BookingPassengerEntity::class,
|
|
139
|
+
],
|
|
140
|
+
version = 2,
|
|
141
|
+
autoMigrations = [
|
|
142
|
+
AutoMigration(from = 1, to = 2),
|
|
143
|
+
],
|
|
144
|
+
exportSchema = true,
|
|
145
|
+
)
|
|
146
|
+
@TypeConverters(Converters::class)
|
|
147
|
+
abstract class AppDatabase : RoomDatabase() {
|
|
148
|
+
abstract fun flightDao(): FlightDao
|
|
149
|
+
abstract fun bookingDao(): BookingDao
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Always set `exportSchema = true` and configure the schema export directory in
|
|
154
|
+
`build.gradle.kts`:
|
|
155
|
+
|
|
156
|
+
```kotlin
|
|
157
|
+
ksp {
|
|
158
|
+
arg("room.schemaLocation", "$projectDir/schemas")
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Reactive Queries with Flow
|
|
163
|
+
|
|
164
|
+
DAO methods returning `Flow` automatically emit new values when the underlying
|
|
165
|
+
table changes.
|
|
166
|
+
|
|
167
|
+
```kotlin
|
|
168
|
+
// DAO returns Flow
|
|
169
|
+
@Query("SELECT * FROM flights WHERE origin_code = :origin")
|
|
170
|
+
fun getFlightsByOrigin(origin: String): Flow<List<FlightEntity>>
|
|
171
|
+
|
|
172
|
+
// Repository maps to domain models
|
|
173
|
+
class FlightRepositoryImpl @Inject constructor(
|
|
174
|
+
private val dao: FlightDao,
|
|
175
|
+
private val mapper: FlightMapper,
|
|
176
|
+
) : FlightRepository {
|
|
177
|
+
|
|
178
|
+
override fun getFlights(origin: String): Flow<List<Flight>> =
|
|
179
|
+
dao.getFlightsByOrigin(origin)
|
|
180
|
+
.map { entities -> entities.map(mapper::toDomain) }
|
|
181
|
+
.flowOn(Dispatchers.IO)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ViewModel collects in viewModelScope
|
|
185
|
+
@HiltViewModel
|
|
186
|
+
class FlightListViewModel @Inject constructor(
|
|
187
|
+
private val repository: FlightRepository,
|
|
188
|
+
) : ViewModel() {
|
|
189
|
+
|
|
190
|
+
val flights: StateFlow<List<Flight>> = repository.getFlights("IST")
|
|
191
|
+
.stateIn(
|
|
192
|
+
scope = viewModelScope,
|
|
193
|
+
started = SharingStarted.WhileSubscribed(5_000),
|
|
194
|
+
initialValue = emptyList(),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Compose collects as state
|
|
199
|
+
@Composable
|
|
200
|
+
fun FlightListScreen(viewModel: FlightListViewModel = hiltViewModel()) {
|
|
201
|
+
val flights by viewModel.flights.collectAsStateWithLifecycle()
|
|
202
|
+
LazyColumn {
|
|
203
|
+
items(flights, key = { it.id }) { flight ->
|
|
204
|
+
FlightCard(flight = flight)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## TypeConverters
|
|
211
|
+
|
|
212
|
+
For types Room does not natively support (lists, enums, dates).
|
|
213
|
+
|
|
214
|
+
```kotlin
|
|
215
|
+
class Converters {
|
|
216
|
+
|
|
217
|
+
private val json = Json { ignoreUnknownKeys = true }
|
|
218
|
+
|
|
219
|
+
@TypeConverter
|
|
220
|
+
fun fromStringList(value: List<String>): String =
|
|
221
|
+
json.encodeToString(value)
|
|
222
|
+
|
|
223
|
+
@TypeConverter
|
|
224
|
+
fun toStringList(value: String): List<String> =
|
|
225
|
+
json.decodeFromString(value)
|
|
226
|
+
|
|
227
|
+
@TypeConverter
|
|
228
|
+
fun fromInstant(value: Instant?): Long? = value?.toEpochMilliseconds()
|
|
229
|
+
|
|
230
|
+
@TypeConverter
|
|
231
|
+
fun toInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
|
|
232
|
+
|
|
233
|
+
@TypeConverter
|
|
234
|
+
fun fromFlightStatus(value: FlightStatus): String = value.name
|
|
235
|
+
|
|
236
|
+
@TypeConverter
|
|
237
|
+
fun toFlightStatus(value: String): FlightStatus = FlightStatus.valueOf(value)
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Register in `@Database`:
|
|
242
|
+
|
|
243
|
+
```kotlin
|
|
244
|
+
@Database(entities = [...], version = 1)
|
|
245
|
+
@TypeConverters(Converters::class)
|
|
246
|
+
abstract class AppDatabase : RoomDatabase()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Relations
|
|
250
|
+
|
|
251
|
+
### @Embedded
|
|
252
|
+
|
|
253
|
+
Flatten a nested object into the parent table columns:
|
|
254
|
+
|
|
255
|
+
```kotlin
|
|
256
|
+
data class AddressEntity(
|
|
257
|
+
val street: String,
|
|
258
|
+
val city: String,
|
|
259
|
+
val country: String,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@Entity(tableName = "passengers")
|
|
263
|
+
data class PassengerEntity(
|
|
264
|
+
@PrimaryKey val id: String,
|
|
265
|
+
val name: String,
|
|
266
|
+
@Embedded(prefix = "address_")
|
|
267
|
+
val address: AddressEntity,
|
|
268
|
+
)
|
|
269
|
+
// Creates columns: id, name, address_street, address_city, address_country
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### @Relation (One-to-Many)
|
|
273
|
+
|
|
274
|
+
```kotlin
|
|
275
|
+
data class BookingWithPassengers(
|
|
276
|
+
@Embedded val booking: BookingEntity,
|
|
277
|
+
@Relation(
|
|
278
|
+
parentColumn = "id",
|
|
279
|
+
entityColumn = "booking_id",
|
|
280
|
+
)
|
|
281
|
+
val passengers: List<BookingPassengerEntity>,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@Dao
|
|
285
|
+
interface BookingDao {
|
|
286
|
+
@Transaction
|
|
287
|
+
@Query("SELECT * FROM bookings WHERE id = :bookingId")
|
|
288
|
+
fun getBookingWithPassengers(bookingId: String): Flow<BookingWithPassengers?>
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Junction Table (Many-to-Many)
|
|
293
|
+
|
|
294
|
+
```kotlin
|
|
295
|
+
@Entity(
|
|
296
|
+
tableName = "flight_tags",
|
|
297
|
+
primaryKeys = ["flight_id", "tag_id"],
|
|
298
|
+
)
|
|
299
|
+
data class FlightTagCrossRef(
|
|
300
|
+
@ColumnInfo(name = "flight_id") val flightId: String,
|
|
301
|
+
@ColumnInfo(name = "tag_id") val tagId: String,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
data class FlightWithTags(
|
|
305
|
+
@Embedded val flight: FlightEntity,
|
|
306
|
+
@Relation(
|
|
307
|
+
parentColumn = "id",
|
|
308
|
+
entityColumn = "id",
|
|
309
|
+
associateBy = Junction(
|
|
310
|
+
value = FlightTagCrossRef::class,
|
|
311
|
+
parentColumn = "flight_id",
|
|
312
|
+
entityColumn = "tag_id",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
val tags: List<TagEntity>,
|
|
316
|
+
)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Always annotate relation queries with `@Transaction` to ensure consistent reads.
|
|
320
|
+
|
|
321
|
+
## Migration Strategies
|
|
322
|
+
|
|
323
|
+
### Auto Migration (Room 2.4+)
|
|
324
|
+
|
|
325
|
+
For simple schema changes (add column, add table, add index):
|
|
326
|
+
|
|
327
|
+
```kotlin
|
|
328
|
+
@Database(
|
|
329
|
+
entities = [FlightEntity::class],
|
|
330
|
+
version = 3,
|
|
331
|
+
autoMigrations = [
|
|
332
|
+
AutoMigration(from = 1, to = 2),
|
|
333
|
+
AutoMigration(from = 2, to = 3, spec = Migration2To3::class),
|
|
334
|
+
],
|
|
335
|
+
)
|
|
336
|
+
abstract class AppDatabase : RoomDatabase()
|
|
337
|
+
|
|
338
|
+
// Spec needed for column/table renames or deletes
|
|
339
|
+
@RenameColumn(tableName = "flights", fromColumnName = "price", toColumnName = "price_amount")
|
|
340
|
+
class Migration2To3 : AutoMigrationSpec
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Manual Migration
|
|
344
|
+
|
|
345
|
+
For complex changes (data transformation, merging tables):
|
|
346
|
+
|
|
347
|
+
```kotlin
|
|
348
|
+
val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
349
|
+
override fun migrate(db: SupportSQLiteDatabase) {
|
|
350
|
+
db.execSQL("""
|
|
351
|
+
CREATE TABLE IF NOT EXISTS bookings_new (
|
|
352
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
353
|
+
flight_id TEXT NOT NULL,
|
|
354
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
355
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
356
|
+
FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
|
|
357
|
+
)
|
|
358
|
+
""")
|
|
359
|
+
db.execSQL("""
|
|
360
|
+
INSERT INTO bookings_new (id, flight_id, status, created_at)
|
|
361
|
+
SELECT id, flight_id, status, 0 FROM bookings
|
|
362
|
+
""")
|
|
363
|
+
db.execSQL("DROP TABLE bookings")
|
|
364
|
+
db.execSQL("ALTER TABLE bookings_new RENAME TO bookings")
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Register in Hilt module
|
|
369
|
+
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
|
|
370
|
+
.addMigrations(MIGRATION_3_4)
|
|
371
|
+
.build()
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Fallback to Destructive Migration
|
|
375
|
+
|
|
376
|
+
Only for development or non-critical data:
|
|
377
|
+
|
|
378
|
+
```kotlin
|
|
379
|
+
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
|
|
380
|
+
.fallbackToDestructiveMigration()
|
|
381
|
+
.build()
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Hilt Integration
|
|
385
|
+
|
|
386
|
+
```kotlin
|
|
387
|
+
@Module
|
|
388
|
+
@InstallIn(SingletonComponent::class)
|
|
389
|
+
object DatabaseModule {
|
|
390
|
+
|
|
391
|
+
@Provides
|
|
392
|
+
@Singleton
|
|
393
|
+
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
|
394
|
+
Room.databaseBuilder(
|
|
395
|
+
context,
|
|
396
|
+
AppDatabase::class.java,
|
|
397
|
+
"app.db",
|
|
398
|
+
)
|
|
399
|
+
.addMigrations(MIGRATION_3_4)
|
|
400
|
+
.build()
|
|
401
|
+
|
|
402
|
+
@Provides
|
|
403
|
+
fun provideFlightDao(database: AppDatabase): FlightDao =
|
|
404
|
+
database.flightDao()
|
|
405
|
+
|
|
406
|
+
@Provides
|
|
407
|
+
fun provideBookingDao(database: AppDatabase): BookingDao =
|
|
408
|
+
database.bookingDao()
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Testing with In-Memory Database
|
|
413
|
+
|
|
414
|
+
```kotlin
|
|
415
|
+
class FlightDaoTest {
|
|
416
|
+
|
|
417
|
+
private lateinit var database: AppDatabase
|
|
418
|
+
private lateinit var dao: FlightDao
|
|
419
|
+
|
|
420
|
+
@Before
|
|
421
|
+
fun setup() {
|
|
422
|
+
database = Room.inMemoryDatabaseBuilder(
|
|
423
|
+
ApplicationProvider.getApplicationContext(),
|
|
424
|
+
AppDatabase::class.java,
|
|
425
|
+
)
|
|
426
|
+
.allowMainThreadQueries() // OK for tests only
|
|
427
|
+
.build()
|
|
428
|
+
|
|
429
|
+
dao = database.flightDao()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@After
|
|
433
|
+
fun tearDown() {
|
|
434
|
+
database.close()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@Test
|
|
438
|
+
fun insertAndQuery_returnsCorrectFlight() = runTest {
|
|
439
|
+
val flight = FlightEntity(
|
|
440
|
+
id = "TK1",
|
|
441
|
+
originCode = "IST",
|
|
442
|
+
originName = "Istanbul",
|
|
443
|
+
destinationCode = "JFK",
|
|
444
|
+
destinationName = "New York",
|
|
445
|
+
departureTime = "2025-01-15T10:00:00Z",
|
|
446
|
+
arrivalTime = "2025-01-15T18:00:00Z",
|
|
447
|
+
priceAmount = 799.0,
|
|
448
|
+
priceCurrency = "USD",
|
|
449
|
+
status = "SCHEDULED",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
dao.insertFlight(flight)
|
|
453
|
+
|
|
454
|
+
val result = dao.getFlightByIdOnce("TK1")
|
|
455
|
+
assertThat(result).isNotNull()
|
|
456
|
+
assertThat(result?.originCode).isEqualTo("IST")
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
@Test
|
|
460
|
+
fun flowQuery_emitsUpdates() = runTest {
|
|
461
|
+
dao.getFlights("IST", "JFK").test {
|
|
462
|
+
assertThat(awaitItem()).isEmpty()
|
|
463
|
+
|
|
464
|
+
dao.insertFlight(testFlight)
|
|
465
|
+
|
|
466
|
+
val updated = awaitItem()
|
|
467
|
+
assertThat(updated).hasSize(1)
|
|
468
|
+
|
|
469
|
+
cancelAndIgnoreRemainingEvents()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@Test
|
|
474
|
+
fun upsert_updatesExistingFlight() = runTest {
|
|
475
|
+
dao.insertFlight(testFlight)
|
|
476
|
+
val updated = testFlight.copy(status = "DELAYED")
|
|
477
|
+
dao.upsertFlights(listOf(updated))
|
|
478
|
+
|
|
479
|
+
val result = dao.getFlightByIdOnce(testFlight.id)
|
|
480
|
+
assertThat(result?.status).isEqualTo("DELAYED")
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Testing Migrations
|
|
486
|
+
|
|
487
|
+
```kotlin
|
|
488
|
+
@RunWith(AndroidJUnit4::class)
|
|
489
|
+
class MigrationTest {
|
|
490
|
+
|
|
491
|
+
@get:Rule
|
|
492
|
+
val helper = MigrationTestHelper(
|
|
493
|
+
InstrumentationRegistry.getInstrumentation(),
|
|
494
|
+
AppDatabase::class.java,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
@Test
|
|
498
|
+
fun migrate3To4() {
|
|
499
|
+
// Create database at version 3
|
|
500
|
+
helper.createDatabase("test-db", 3).apply {
|
|
501
|
+
execSQL("INSERT INTO bookings (id, flight_id, status) VALUES ('B1', 'F1', 'CONFIRMED')")
|
|
502
|
+
close()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Run migration and validate
|
|
506
|
+
val db = helper.runMigrationsAndValidate("test-db", 4, true, MIGRATION_3_4)
|
|
507
|
+
val cursor = db.query("SELECT * FROM bookings WHERE id = 'B1'")
|
|
508
|
+
assertThat(cursor.moveToFirst()).isTrue()
|
|
509
|
+
assertThat(cursor.getColumnIndex("created_at")).isAtLeast(0)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Encrypted Database (SQLCipher)
|
|
515
|
+
|
|
516
|
+
Encrypt the database file by passing a SQLCipher `SupportOpenHelperFactory` to
|
|
517
|
+
`.openHelperFactory(...)`. This skill owns only the Room wiring. The passphrase
|
|
518
|
+
must come from a hardware-backed key hierarchy; the derivation and storage of
|
|
519
|
+
that key are the `android-security` skill's responsibility, not reproduced here.
|
|
520
|
+
|
|
521
|
+
```kotlin
|
|
522
|
+
fun buildEncryptedDatabase(
|
|
523
|
+
context: Context,
|
|
524
|
+
passphrase: ByteArray,
|
|
525
|
+
): AppDatabase {
|
|
526
|
+
val factory = SupportOpenHelperFactory(passphrase)
|
|
527
|
+
return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
|
|
528
|
+
.openHelperFactory(factory)
|
|
529
|
+
.addCallback(WalHygieneCallback())
|
|
530
|
+
.build()
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
`SupportOpenHelperFactory` is from the AndroidX-compatible SQLCipher artifact
|
|
535
|
+
(`net.zetetic.database.sqlcipher`); it zeroes the passphrase array after use.
|
|
536
|
+
|
|
537
|
+
## Async Database Provisioning
|
|
538
|
+
|
|
539
|
+
When the passphrase must be derived or fetched before `Room.databaseBuilder`, a
|
|
540
|
+
synchronous `@Provides` cannot express the await. Expose the database through a
|
|
541
|
+
suspend, `Mutex`-guarded, double-checked lazy singleton provider.
|
|
542
|
+
|
|
543
|
+
```kotlin
|
|
544
|
+
@Singleton
|
|
545
|
+
class DatabaseProvider @Inject constructor(
|
|
546
|
+
@ApplicationContext private val context: Context,
|
|
547
|
+
private val keyProvider: DatabaseKeyProvider,
|
|
548
|
+
) {
|
|
549
|
+
private val mutex = Mutex()
|
|
550
|
+
@Volatile private var instance: AppDatabase? = null
|
|
551
|
+
|
|
552
|
+
suspend fun database(): AppDatabase {
|
|
553
|
+
instance?.let { return it }
|
|
554
|
+
return mutex.withLock {
|
|
555
|
+
instance ?: buildDatabase().also { instance = it }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private suspend fun buildDatabase(): AppDatabase {
|
|
560
|
+
val passphrase = keyProvider.passphrase()
|
|
561
|
+
return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
|
|
562
|
+
.openHelperFactory(SupportOpenHelperFactory(passphrase))
|
|
563
|
+
.addCallback(WalHygieneCallback())
|
|
564
|
+
.build()
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
The Hilt module provides `DatabaseProvider` (no I/O); repositories obtain DAOs
|
|
570
|
+
from a suspend accessor (`provider.database().recentSearchDao()`) at call time.
|
|
571
|
+
|
|
572
|
+
## Runtime Hygiene
|
|
573
|
+
|
|
574
|
+
Cap WAL growth on a write-heavy database with an `onOpen` PRAGMA:
|
|
575
|
+
|
|
576
|
+
```kotlin
|
|
577
|
+
class WalHygieneCallback : RoomDatabase.Callback() {
|
|
578
|
+
override fun onOpen(db: SupportSQLiteDatabase) {
|
|
579
|
+
db.query("PRAGMA wal_autocheckpoint=1000").close()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Do multi-step read-then-write logic atomically in a `@Transaction` DAO default
|
|
585
|
+
method -- dedupe, insert, and trim-to-cap in one transaction:
|
|
586
|
+
|
|
587
|
+
```kotlin
|
|
588
|
+
@Dao
|
|
589
|
+
interface RecentSearchDao {
|
|
590
|
+
@Query("SELECT id FROM recent_searches WHERE query = :query LIMIT 1")
|
|
591
|
+
suspend fun findId(query: String): Long?
|
|
592
|
+
|
|
593
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
594
|
+
suspend fun insert(entity: RecentSearchEntity): Long
|
|
595
|
+
|
|
596
|
+
@Query(
|
|
597
|
+
"DELETE FROM recent_searches WHERE id IN " +
|
|
598
|
+
"(SELECT id FROM recent_searches ORDER BY updated_at DESC LIMIT -1 OFFSET :cap)",
|
|
599
|
+
)
|
|
600
|
+
suspend fun trimToCap(cap: Int)
|
|
601
|
+
|
|
602
|
+
@Transaction
|
|
603
|
+
suspend fun record(entity: RecentSearchEntity, cap: Int) {
|
|
604
|
+
val existing = findId(entity.query)
|
|
605
|
+
insert(if (existing != null) entity.copy(id = existing) else entity)
|
|
606
|
+
trimToCap(cap)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
`exportSchema = false` is acceptable only for a cache or destructive-fallback
|
|
612
|
+
database, where the file is disposable. A migration-tested production database
|
|
613
|
+
must keep `exportSchema = true` and commit the schema JSON, since
|
|
614
|
+
`MigrationTestHelper` validates against the exported schema.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-review
|
|
3
|
+
description: "Run a defensive, static security review of a diff or a repo: build a run-scoped threat model, find vulnerabilities joined to OWASP + CWE with CVSS scoring and evidence, and emit reviewer-shaped findings with before/after fixes. Use when reviewing code for security, auditing a dependency set, preparing a release for a security sign-off, or when a diff touches auth, crypto, input handling, networking or secrets."
|
|
4
|
+
risk: low
|
|
5
|
+
source: multi-agent-pipeline
|
|
6
|
+
date_added: "2026-09-21"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Security Review (defensive, static)
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
A static, read-only security review. It reads code, configuration and dependency manifests and reports vulnerabilities the way the pipeline can act on them: each finding joined to an OWASP category and a CWE, scored with CVSS 3.1, backed by evidence and its counterevidence, and paired with a before/after fix. It never runs the target, fires a payload, or reaches a live host - the posture is defensive and offline. The output is reviewer-shaped, so a blocking finding merges into triage and blocks the commit like any other blocker.
|
|
14
|
+
|
|
15
|
+
This is stack-neutral. The same method covers a Swift or Kotlin mobile diff, a TypeScript / Python / Go / Java backend, and a web front-end. What changes per stack is the OWASP catalog (Mobile Top 10 for apps, Web/API Top 10 for services and sites) and the sink vocabulary, not the method.
|
|
16
|
+
|
|
17
|
+
## When to Use This Skill
|
|
18
|
+
|
|
19
|
+
- Reviewing a diff whose paths touch authentication, authorization, crypto, input handling, networking, deserialization, file access or secrets.
|
|
20
|
+
- Auditing a dependency set for known-vulnerable packages before a release.
|
|
21
|
+
- Preparing a branch for a security sign-off, or answering "is this change safe to ship".
|
|
22
|
+
- Any run where `/multi-agent:security-review` is invoked, or Phase 3 fires the `security_path` signal.
|
|
23
|
+
|
|
24
|
+
## How It Works
|
|
25
|
+
|
|
26
|
+
### Step 1: Build the run-scoped threat model
|
|
27
|
+
|
|
28
|
+
Before any finding, establish the four-section threat model for THIS change and write it to `.pipeline/threat-model.md` (contract: `threat-model.md` in the pipeline refs). Attacker, trust boundaries, attack surface, severity calibration. Every finding's severity is calibrated against it, and a `blocking` finding must trace to a named assumption in it. A diff with no plausible attacker gets a short model and findings that cap at `suggestion`.
|
|
29
|
+
|
|
30
|
+
### Step 2: Map the surface to a standard
|
|
31
|
+
|
|
32
|
+
Join every candidate finding to a checkable standard, never a bare opinion:
|
|
33
|
+
|
|
34
|
+
- **Web / API** - OWASP Top 10 2021 (`references/owasp-web-api-top10-2021.md`), with the CWE most associated with each category.
|
|
35
|
+
- **Mobile** - OWASP Mobile Top 10 2024 (`references/owasp-mobile-top10-2024.md`).
|
|
36
|
+
|
|
37
|
+
Walk the checklist against the changed surface only. A finding outside the touched surface is out of scope unless the diff made it reachable, and then you say how.
|
|
38
|
+
|
|
39
|
+
### Step 3: Score with CVSS, and keep the number honest
|
|
40
|
+
|
|
41
|
+
For each finding, write a CVSS 3.1 base vector and compute the score with the `security_cvss_score` toolkit tool - never by hand, so the score cannot drift from the vector. The band sets the reviewer severity: critical or high -> `blocking`, medium -> `important`, low or none -> `suggestion`.
|
|
42
|
+
|
|
43
|
+
Every finding carries:
|
|
44
|
+
|
|
45
|
+
- **evidence** - what in the code proves it, cited by `file:line`. No evidence, no finding.
|
|
46
|
+
- **counterevidence** - what would disprove it, or the condition that makes it a false positive. Blank asserts there is none.
|
|
47
|
+
- **confidence** - `high|medium|low`. Low does not mean silent; it means report with the counterevidence.
|
|
48
|
+
- **severityChangeConditions** - the assumption the score rests on, so a reader disputes the assumption, not the number.
|
|
49
|
+
|
|
50
|
+
### Step 4: Inventory dependencies (offline)
|
|
51
|
+
|
|
52
|
+
Run `security_dep_inventory` on the lockfiles in scope to get a normalized `{ecosystem, name, version}` list. Hand that list to a known-CVE lookup (the analyst toolkit's evidence-registry) as a separate step - this skill contacts nothing itself. A vulnerable dependency is `A06:2021` with the advisory's CWE and CVE, on the manifest file at line 0.
|
|
53
|
+
|
|
54
|
+
### Step 5: Emit reviewer-shaped findings
|
|
55
|
+
|
|
56
|
+
Emit one object conforming to `reviewer-output.schema.json`, each finding also conforming to `security-finding.schema.json` (the `security` block). Include the before/after fix as `security.remediationDiff`, and `security.fixVerification` naming the empirical check a human or a later dynamic pass would run - static review cannot fire the exploit itself. `approved` is `false` if any finding is `blocking`.
|
|
57
|
+
|
|
58
|
+
An empty findings list with `approved: true` is the right answer for a clean diff. Do not invent findings to look thorough, and do not raise a theoretical risk with no path from the threat model's attacker above `suggestion`.
|
|
59
|
+
|
|
60
|
+
## What This Skill Does Not Do
|
|
61
|
+
|
|
62
|
+
- No live target, no payloads, no exploitation, no proxy, no browser-driven attack. Defensive and static only.
|
|
63
|
+
- No secret scanning of its own: the pipeline already has that surface (`pre-commit-check.sh` / the egress gate, held in parity against `secret-patterns.json`). Call it; do not hand-roll a fourth list.
|
|
64
|
+
- No store-policy audit: that is the separate `store-ready` device pass under `/multi-agent:test`.
|