@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +5 -5
  3. package/README.tr.md +5 -5
  4. package/SECURITY.md +3 -3
  5. package/docs/adr/0011-dormant-ci.md +10 -1
  6. package/docs/architecture.md +2 -2
  7. package/docs/ecosystem.md +5 -5
  8. package/docs/facts.json +8 -7
  9. package/install/_codex-agents.mjs +1 -1
  10. package/manifest.json +92 -64
  11. package/package.json +1 -1
  12. package/pipeline/agents/code-reviewer.md +2 -2
  13. package/pipeline/agents/dev-critic.md +5 -5
  14. package/pipeline/agents/security-auditor.md +80 -72
  15. package/pipeline/commands/figma-to-swiftui.md +1 -1
  16. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  17. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  20. package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
  21. package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
  22. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  23. package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
  24. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
  25. package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
  26. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  27. package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
  28. package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
  29. package/pipeline/multi-agent-refs/threat-model.md +39 -0
  30. package/pipeline/schemas/agent-state.schema.json +23 -0
  31. package/pipeline/schemas/phases.json +1 -2
  32. package/pipeline/schemas/prefs.schema.json +0 -4
  33. package/pipeline/schemas/reviewer-output.schema.json +99 -2
  34. package/pipeline/schemas/security-finding.schema.json +144 -0
  35. package/pipeline/scripts/_stack-routing.mjs +1 -0
  36. package/pipeline/scripts/gc-abandoned.sh +16 -9
  37. package/pipeline/scripts/render-work-summary.sh +7 -4
  38. package/pipeline/skills/.skill-manifest.json +47 -23
  39. package/pipeline/skills/.skills-index.json +75 -9
  40. package/pipeline/skills/shared/README.md +13 -7
  41. package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
  42. package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
  43. package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
  44. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  46. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  47. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  48. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  49. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  50. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  51. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  52. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  53. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  54. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  55. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  56. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  57. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  58. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  59. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  60. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  61. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  62. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  63. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  64. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  65. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  66. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  67. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  68. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  69. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  70. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  71. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  72. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  73. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  74. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  75. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  76. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  77. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  78. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  79. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  80. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  81. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  82. package/pipeline/skills/shared/external/security-review/SKILL.md +64 -0
  83. package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
  84. package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -0
  85. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  86. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  87. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  88. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  89. package/pipeline/skills/skills-index.md +8 -2
  90. 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`.