@ak--47/dungeon-master 1.7.0 → 1.8.1

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 (43) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +30 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +84 -44
  3. package/.claude/skills/create-project/SKILL.md +28 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +39 -12
  7. package/.claude/skills/powertools/SKILL.md +26 -3
  8. package/.claude/skills/release-check/SKILL.md +124 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +103 -29
  10. package/.claude/skills/verify-dungeon/references/alignment-contract.md +84 -0
  11. package/.claude/skills/verify-dungeon/references/counting-semantics.md +41 -16
  12. package/.claude/skills/verify-dungeon/references/report-format.md +41 -10
  13. package/.claude/skills/verify-dungeon/references/sql-recipes.md +171 -226
  14. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  15. package/.claude/skills/warehouse-metrics/SKILL.md +111 -0
  16. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  17. package/.claude/skills/write-hooks/SKILL.md +94 -51
  18. package/CHANGELOG.md +183 -0
  19. package/HOOKS.md +165 -18
  20. package/README.md +265 -1
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/docs/guides/1.8.1-upgrade-guide.md +153 -0
  23. package/dungeons/technical/warehouse.js +187 -0
  24. package/index.js +116 -2
  25. package/lib/core/config-validator.js +21 -0
  26. package/lib/core/dungeon-loader.js +1 -1
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +6 -0
  29. package/lib/generators/funnels.js +15 -0
  30. package/lib/generators/standalone.js +248 -0
  31. package/lib/generators/warehouse.js +828 -0
  32. package/lib/hook-helpers/shape.js +73 -17
  33. package/lib/orchestrators/mixpanel-sender.js +27 -2
  34. package/lib/orchestrators/user-loop.js +83 -15
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/utils.js +37 -12
  37. package/lib/verify/funnel-engine.js +66 -26
  38. package/lib/verify/index.js +1 -0
  39. package/lib/verify/story-runner.js +71 -8
  40. package/lib/verify/warehouse.js +683 -0
  41. package/package.json +4 -2
  42. package/scripts/verify-stories.mjs +150 -44
  43. package/types.d.ts +312 -9
@@ -2,26 +2,18 @@
2
2
 
3
3
  Use DuckDB only for schema integrity, identity-model invariants, experiment invariants, and bespoke patterns the emulator can't express. For funnel / frequency / aggregate / TTC / attribution patterns, use `emulateBreakdown` instead — see [counting-semantics.md](counting-semantics.md).
4
4
 
5
- ## Schema validation queries
6
-
7
- For each unique event type in the output, compare actual columns against the config-declared properties:
5
+ Apply the [1.8.1 verification contract](alignment-contract.md) to every query.
6
+ The SQL examples below are diagnostics for their named measures. They cannot
7
+ replace a different report's acceptance check. Use explicit report options,
8
+ paired baselines, neutral controls, and actual eligible population counts.
8
9
 
9
- ```sql
10
- WITH event_data AS (
11
- SELECT * FROM read_json_auto('./data/<run-name>-EVENTS.json', sample_size=-1)
12
- WHERE event = '<EVENT_TYPE>'
13
- )
14
- SELECT
15
- unnest(map_keys(columns(*))) as col_name,
16
- COUNT(*) as total_events,
17
- COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) as non_null_count,
18
- ROUND(COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) * 100.0 / COUNT(*), 1) as coverage_pct
19
- FROM event_data
20
- GROUP BY col_name
21
- ORDER BY coverage_pct DESC;
22
- ```
10
+ ## Schema validation queries
23
11
 
24
- Or use the programmatic API (`lib/verify/schema-validator.js`):
12
+ For each event type, compare raw record keys against config-declared properties
13
+ and enabled engine/SDK fields. SQL unioned columns lose per-record key presence;
14
+ null coverage alone cannot distinguish an absent key from a declared null.
15
+ Use the programmatic API (`lib/verify/schema-validator.js`) to derive expected
16
+ keys, then explicitly fail any undeclared key in the raw records:
25
17
 
26
18
  ```javascript
27
19
  import { deriveExpectedSchema, validateSchema } from './lib/verify/index.js';
@@ -53,17 +45,57 @@ The expected set of columns per event type is derived from config:
53
45
 
54
46
  For each event type, classify any column present in output but NOT in expected schema:
55
47
 
56
- - **SCHEMA-PASS** Column appears on 100% of events of this type. Uniform enrichment is acceptable.
57
- - **SCHEMA-FAIL** Column appears on <100% of events of this type. This is flag stamping hook conditionally adds a property, creating an inconsistent schema.
48
+ - **SCHEMA-PASS** - every observed key is declared or a recognized enabled engine/SDK field.
49
+ - **SCHEMA-FAIL** - any undeclared key, even at 100% coverage. Uniform enrichment does not bypass schema-first authorship.
50
+
51
+ The runtime summary may permit uniform enrichment. Retain its output, but apply
52
+ the stricter authorship gate separately. Declared nullable fields remain valid.
58
53
 
59
54
  If any event type has SCHEMA-FAIL, flag it prominently and include specific remediation: which hook line adds the property and how to remove it while preserving the intended pattern.
60
55
 
61
56
  ## Standard identity-model invariants
62
57
 
63
- Run these for every dungeon that uses the identity model (`isAuthEvent` + `attempts` + `identity.avgDevicePerUser`), BEFORE per-pattern checks:
58
+ ### Metric artifacts use separate schemas
59
+
60
+ The expected-schema table above applies only to user EVENTS. For `standaloneEvents`,
61
+ expect `event`, `time`, `insert_id`, `distinct_id`, plus the matching spec's
62
+ dimension and property keys. User superProps, session ids, and SDK flags do not
63
+ apply. Check undeclared columns explicitly; the story CLI's user schema pass
64
+ does not cover standalone shards.
65
+
66
+ Use a `duckdb` story assertion with this source, filtering the declared event:
64
67
 
65
68
  ```sql
66
- -- Stitch event count must match converted-born count, exactly one per user.
69
+ SELECT event, count(*) AS records, min(time::TIMESTAMP) AS first_tick,
70
+ max(time::TIMESTAMP) AS last_tick
71
+ FROM read_json_auto('{{PREFIX}}-STANDALONE*.json',
72
+ union_by_name=true, sample_size=-1)
73
+ GROUP BY event;
74
+ ```
75
+
76
+ Compare the count with cadence ticks times the dimension cross-product size.
77
+ Check duplicate `(event, time, <dimension keys>)` tuples and undeclared keys,
78
+ and assert that `user_id` and `device_id` are absent. Synthetic `distinct_id`
79
+ values identify series, never people. Do not union these shards into EVENTS
80
+ for funnels, retention, stitching, or user counts. Substitute `{{PREFIX}}`
81
+ with the exact artifact prefix when running SQL outside the story CLI.
82
+
83
+ For `warehouseMetrics`, use the matching `-WAREHOUSE-MANIFEST.json` to resolve
84
+ each table file and schema. Read JSONL with `read_json_auto` or CSV with
85
+ `read_csv_auto` according to that manifest. Check the declared `timeColumn`,
86
+ group keys, `valueColumn`, and extra `columns`; no identity fields are required.
87
+ Use `warehouse` or `warehouse-stats` assertions for stories. The
88
+ automatic warehouse audit runs even without stories. Account for `history`
89
+ backfill and `sparse` point-in-time rows before judging counts or time coverage.
90
+
91
+ ### User-event identity checks
92
+
93
+ Run identity checks whenever the dungeon uses device identity, before per-pattern
94
+ checks. Count configured auth rows as a diagnostic, not as a universal one-stitch
95
+ invariant; ordinary both-ID events can also establish a link.
96
+
97
+ ```sql
98
+ -- Diagnostic counts for a configured auth event, not a mapping proof.
67
99
  WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
68
100
  auth_event AS (SELECT 'Sign Up' AS name) -- name of your isAuthEvent
69
101
  SELECT
@@ -71,17 +103,14 @@ SELECT
71
103
  SUM(CASE WHEN user_id IS NOT NULL AND device_id IS NOT NULL THEN 1 ELSE 0 END) AS stitches,
72
104
  COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN user_id END) AS converted_users
73
105
  FROM e WHERE event = (SELECT name FROM auth_event);
74
-
75
- -- Pre-existing users must have user_id on every event (no anon-only records).
76
- WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
77
- u AS (SELECT * FROM read_json_auto('./data/<file>-USERS.json'))
78
- SELECT COUNT(*) AS preexisting_anon_only_records
79
- FROM e JOIN u ON u.distinct_id::VARCHAR = e.user_id::VARCHAR
80
- WHERE u.created < (SELECT MIN(time::TIMESTAMP) FROM e)
81
- AND e.user_id IS NULL;
82
106
  ```
83
107
 
84
- Failures usually indicate incomplete identity-model migration. Flag in report.
108
+ Build the proof map from valid emitted both-ID events, including later ordinary
109
+ Login events, and resolve earlier device-only rows retrospectively. Inspect
110
+ conflicting links. Profile pools are not mapping evidence. For pre-existing
111
+ stamping, use generator ownership evidence and resolved dataset bounds; joining
112
+ on `e.user_id` and then testing it for NULL can never detect missing IDs. If row
113
+ ownership is unavailable in retained artifacts, report that check as unproved.
85
114
 
86
115
  ## Experiment invariants
87
116
 
@@ -232,7 +261,10 @@ FROM events
232
261
  GROUP BY period;
233
262
  ```
234
263
 
235
- ### Retention / Churn (e.g., "early guild joiners retain better")
264
+ ### Activity-span diagnostic (not a retention report)
265
+
266
+ This measures first-to-last activity span. Use `retention` with the report's
267
+ cohort, return event, buckets, and mature horizon for a retention claim.
236
268
  ```sql
237
269
  WITH user_first_event AS (
238
270
  SELECT user_id, MIN(time::TIMESTAMP) as first_seen
@@ -284,29 +316,13 @@ JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON b.user_id = e.user
284
316
  GROUP BY b.is_target_buyer;
285
317
  ```
286
318
 
287
- ### Funnel Conversion by Segment (when emulator can't do it)
288
- ```sql
289
- WITH step1 AS (
290
- SELECT DISTINCT user_id, segment_prop
291
- FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
292
- WHERE event = 'funnel_step_1'
293
- ),
294
- step2 AS (
295
- SELECT DISTINCT user_id
296
- FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
297
- WHERE event = 'funnel_step_2'
298
- )
299
- SELECT
300
- s1.segment_prop,
301
- COUNT(DISTINCT s1.user_id) as started,
302
- COUNT(DISTINCT s2.user_id) as completed,
303
- ROUND(COUNT(DISTINCT s2.user_id) * 100.0 / COUNT(DISTINCT s1.user_id), 2) as conversion_pct
304
- FROM step1 s1
305
- LEFT JOIN step2 s2 ON s1.user_id = s2.user_id
306
- GROUP BY s1.segment_prop;
307
- ```
319
+ ### Funnel conversion by segment
308
320
 
309
- For Mixpanel-accurate funnel verification, prefer `emulateBreakdown({type: 'funnelFrequency'})` see [counting-semantics.md](counting-semantics.md).
321
+ Use `emulateBreakdown({type: 'funnelFrequency'})` with explicit report options.
322
+ A join between users who did A and users who did B does not check ordered
323
+ completion, restart, grace, exclusions, or the conversion window. If the emulator
324
+ cannot express the requested report, record the semantic gap instead of substituting
325
+ an unordered SQL intersection. See [counting-semantics.md](counting-semantics.md).
310
326
 
311
327
  ### Property Distribution Shift
312
328
  ```sql
@@ -331,34 +347,13 @@ WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'
331
347
  GROUP BY period;
332
348
  ```
333
349
 
334
- ### Value Magnitude by Behavioral Segment (sessionize derived cohorts)
335
- ```sql
336
- WITH ordered AS (
337
- SELECT *, time::TIMESTAMP as ts,
338
- LAG(time::TIMESTAMP) OVER (PARTITION BY user_id ORDER BY time) as prev_ts
339
- FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
340
- ),
341
- sessions AS (
342
- SELECT user_id,
343
- SUM(CASE WHEN prev_ts IS NULL OR ts - prev_ts > INTERVAL '30 minutes' THEN 1 ELSE 0 END) as session_count
344
- FROM ordered
345
- GROUP BY user_id
346
- ),
347
- segments AS (
348
- SELECT user_id,
349
- CASE WHEN session_count > 20 THEN 'power_user' ELSE 'regular' END as segment
350
- FROM sessions
351
- )
352
- SELECT
353
- seg.segment,
354
- COUNT(*) as purchase_count,
355
- ROUND(AVG(TRY_CAST(e.amount AS DOUBLE)), 2) as avg_amount,
356
- COUNT(DISTINCT seg.user_id) as users
357
- FROM segments seg
358
- JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON seg.user_id = e.user_id
359
- WHERE e.event = 'purchase'
360
- GROUP BY seg.segment;
361
- ```
350
+ ### Value magnitude by session-derived cohort
351
+
352
+ Derive sessions with the verifier from the full resolved user stream before
353
+ filtering events or partitioning by hold-property value. Use all three split
354
+ rules (idle timeout, maximum duration, UTC day change). A timeout-only SQL `LAG`
355
+ query and generator-stamped `session_id` do not establish this contract. Export
356
+ the resulting user/cohort mapping for a SQL value diagnostic if needed.
362
357
 
363
358
  ### Temporal Value Scaling (e.g., 3x amounts on 1st/15th)
364
359
  ```sql
@@ -435,7 +430,9 @@ SELECT 'bots' as metric, count(*) FROM read_json_auto('./data/verify-dungeon-USE
435
430
  UNION ALL SELECT 'null_props', count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE category IS NULL;
436
431
  ```
437
432
 
438
- Advanced feature patterns should ALWAYS be present (deterministic from config), unlike hooks which may have statistical variance.
433
+ Check whether the relevant population and time window are present before asserting
434
+ an advanced-feature effect. Seeded determinism does not guarantee that a finite
435
+ sample contains every configured segment or outcome.
439
436
 
440
437
  **Deprecated config blocks (silently stripped by validator since 1.4):**
441
438
  `subscription`, `attribution`, `geo`, `features`, `anomalies`. If a
@@ -465,7 +462,8 @@ For pre-existing-only dungeons (`percentUsersBornInDataset: 0`) expect
465
462
  `dropped = 0`.
466
463
 
467
464
  ### 1. SuperProp Consistency
468
- Verify each user has exactly 1 value per superProp:
465
+ For properties declared in `stickyEventProps` or explicitly promised as stable,
466
+ check per-user consistency. Other `superProps` may legitimately vary by event:
469
467
 
470
468
  ```sql
471
469
  SELECT
@@ -480,10 +478,13 @@ FROM (
480
478
  GROUP BY user_id
481
479
  );
482
480
  ```
483
- Verdict: **STRONG** ≥99% consistent, **WEAK** 90-99%, **FAIL** <90%.
481
+ For a strict profile projection contract, investigate every mismatch. Do not
482
+ replace the declared contract with a generic percentage tolerance.
484
483
 
485
484
  ### 2. SuperProp-UserProp Mirror Check
486
- Every superProp key should also appear on user profiles. Compare the dungeon's `superProps` keys against columns in the USERS file. Any superProp not mirrored in `userProps` means the stamping fix is incomplete.
485
+ Only keys promised as profile projections must mirror `userProps`. Matching
486
+ enumerations alone do not guarantee equality; use `stickyEventProps`. Event-only
487
+ context such as an app version does not require a profile mirror.
487
488
 
488
489
  ### 3. Mixpanel Default Property Casing Check
489
490
  The system generates device properties with Mixpanel's standard casing
@@ -501,9 +502,9 @@ intentional opt-in — not a casing conflict.
501
502
 
502
503
  ### 4. funnel-pre Dilution Check
503
504
  For any dungeon with `funnel-pre` conversionRate modifications, verify the actual visible effect:
504
- - A `conversionRate *= 1.5` in funnel-pre typically shows as ~1.02-1.08x in the data (diluted by organic events)
505
- - If observed ratio is <1.1x for a funnel-pre conversionRate hook, verdict is **FAIL** with note: "funnel-pre conversionRate diluted by organic events — migrate to `everything` hook event filtering"
506
- - When the dungeon uses `everything` hook filtering instead, expect the full intended ratio (1.3-1.5x)
505
+ - Compare the declared funnel report on paired baseline and treatment streams.
506
+ - Inspect organic competitors, repeated opportunities, saturation, and eligible populations.
507
+ - Keep the target and report fixed; neither funnel-pre scaling nor everything filtering guarantees a universal ratio.
507
508
 
508
509
  ## Population threshold validation
509
510
 
@@ -520,14 +521,15 @@ GROUP BY segment_column
520
521
  ORDER BY users DESC;
521
522
  ```
522
523
 
523
- **Thresholds (at 1K users):**
524
- - Segment <20 users (<2%): hook signal will be WEAK or invisible — flag as "insufficient population"
525
- - Segment 20-50 users: may show signal but with high variance — note in report
526
- - Segment >50 users: should show clear signal if hook effect ≥1.3x
524
+ Set population floors before measuring, based on the intended effect and report.
525
+ Report actual independent eligible users and converters on both sides. No fixed
526
+ user count guarantees a clear signal for every effect or distribution.
527
527
 
528
528
  ## Statistical caveats
529
529
 
530
- This skill always runs at full fidelity (the dungeon's own scale). At full fidelity, cohorts of all sizes should produce clear signal because the absolute population is large. WEAK or FAIL results at full fidelity indicate a real problem — investigate, do not retry at smaller scale.
530
+ This skill uses the dungeon's configured scale for acceptance. Full fidelity does
531
+ not guarantee enough eligible users, converters, or mature cohorts. Distinguish
532
+ `INSUFFICIENT_EVIDENCE` from measured failure and investigate each accordingly.
531
533
 
532
534
  `--small` mode is a developer-troubleshooting escape hatch on the runner script; verdicts from `--small` runs are unreliable and not permitted in this skill's output.
533
535
 
@@ -579,35 +581,15 @@ GROUP BY b.bucket;
579
581
 
580
582
  When the dungeon doesn't have a natural "per-X" denominator, compute one from the cohort-binning event: `target_events / cohort_event_count`.
581
583
 
582
- **Time-to-convert (funnel-post) verification** compute median A→B time per profile segment:
584
+ **Time-to-convert verification:** use the steps-based `timeToConvert` emulator
585
+ with explicit report options and completed histories. Independent MIN(A)/MIN(B)
586
+ timestamps can mix attempts. Preserve the requested statistic and derive its
587
+ target from the hook, with a measured factor-one or hook-disabled control. Two
588
+ differently modified segments do not constitute a neutral baseline.
583
589
 
584
- ```sql
585
- WITH funnel AS (
586
- SELECT user_id,
587
- MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_A>') AS a_time,
588
- MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_B>') AS b_time
589
- FROM read_json_auto('./data/<run>-EVENTS.json')
590
- GROUP BY user_id
591
- )
592
- SELECT u.<SEGMENT_KEY>,
593
- COUNT(*) AS users,
594
- ROUND(MEDIAN(EXTRACT(EPOCH FROM (b_time - a_time)) / 60), 2) AS median_min_a_to_b
595
- FROM funnel f
596
- JOIN read_json_auto('./data/<run>-USERS.json') u ON f.user_id = u.distinct_id
597
- WHERE a_time IS NOT NULL AND b_time IS NOT NULL
598
- GROUP BY u.<SEGMENT_KEY>
599
- ORDER BY median_min_a_to_b;
600
- ```
601
-
602
- **Verdict for T2C**:
603
- - Fast segment ≤0.85x baseline → STRONG
604
- - Slow segment ≥1.2x baseline → STRONG
605
- - Both directions visible → STRONG
606
- - One/both missing → check that funnel exists in `funnels:` config and segment property is on `meta.profile`
607
-
608
- **Two-tier T2C interpretation**: When a dungeon has only 2 tiers (e.g. Free vs Paid) the funnel-post hook factor `1.0` branch never fires — both tiers fall into either fast or slow. Pick the slower of the two as the implicit baseline, then verify the faster shows ≤0.85x of it.
609
-
610
- **No-flag verification rule**: NEVER attempt to verify a hook by querying for a flag like `WHERE sweet_spot = true`. If a dungeon has such flags, treat them as a doc bug — the hook should be reworked to hide the cohort behaviorally. The validator's job is to derive cohorts behaviorally.
590
+ **No-flag verification rule:** derive hidden cohorts behaviorally or with the
591
+ declared hash. A flag with a schema-declared default is allowed; an undeclared
592
+ flag fails even when present on every record.
611
593
 
612
594
  ## Drop-event funnel dilution diagnosis
613
595
 
@@ -615,22 +597,27 @@ Many dungeons have hooks of pattern `record.filter(e => e.event === 'X' && chanc
615
597
 
616
598
  **Why:** the hook drops EVENTS not users. A user with 5 step-3 events still appears in the funnel after losing 1-2 events. Funnel completion = `users with ≥1 step-3 event` — only zero-step-3 users disappear from the conversion count, which is rare.
617
599
 
618
- **Correct verification metric:** per-user volume of step-3 events by tier:
600
+ **Supplementary diagnostic:** per-user volume of step-3 events by tier. This
601
+ does not replace the declared funnel completion check:
619
602
 
620
603
  ```sql
621
604
  SELECT u.subscription_tier,
622
605
  COUNT(DISTINCT user_id) AS users,
623
606
  COUNT(*) AS total_step3,
624
607
  ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT user_id), 2) AS per_user
625
- FROM read_json_auto('./data/<run>-EVENTS.json')
626
- WHERE event = '<STEP_3_EVENT>'
608
+ FROM read_json_auto('./data/<run>-EVENTS.json') e
609
+ JOIN read_json_auto('./data/<run>-USERS.json') u
610
+ ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
611
+ WHERE e.event = '<STEP_3_EVENT>'
627
612
  GROUP BY u.subscription_tier
628
613
  ORDER BY per_user DESC;
629
614
  ```
630
615
 
631
616
  Expected: paid tier ~1.5x non-paid per_user (matches 30% drop on non-paid → paid keeps 100%, non-paid keeps 70%, ratio 1/0.7 = 1.43x).
632
617
 
633
- If funnel completion gap <5pt but per_user gap ≥30%, the hook IS firing the doc just points to the wrong metric. Mark STRONG, recommend doc redirect to per-user query.
618
+ A volume gap can show the mutation fired while the declared conversion story
619
+ still fails. Preserve that failure. Change the report only through an explicit
620
+ story revision, then verify the revised claim with its own controls.
634
621
 
635
622
  ## Subscription tier cohort sizing check
636
623
 
@@ -641,12 +628,10 @@ SELECT subscription_plan, COUNT(*) FROM read_json_auto('./data/<run>-USERS.json'
641
628
  GROUP BY subscription_plan;
642
629
  ```
643
630
 
644
- The default subscription lifecycle (`trialToPayRate=0.30`, `upgradeRate=0.06-0.08`) produces ~85% NULL/Free, ~10-15% Monthly, <2% Annual, ~0% Family at 5K users. Cohorts <50 users will not produce statistically clean signal at any effect size.
645
-
646
- **If annual cohort <50 users:**
647
- - Don't trust per-tier ratios note "cohort too small" in results.md
648
- - Bump `numUsers` up to 5x to enlarge cohorts
649
- - Or recommend dungeon author tighten subscription lifecycle config
631
+ There is no active subscription lifecycle config block. Read the declared
632
+ `userProps` distribution and hook logic, then measure eligible populations.
633
+ If evidence is insufficient, request a larger run or an authorized schema change;
634
+ do not invent lifecycle defaults or silently relax the acceptance threshold.
650
635
 
651
636
  ## Per-day normalization for time-window hooks
652
637
 
@@ -666,41 +651,40 @@ For any spike/burst hook with a tight day window, ALWAYS normalize by window len
666
651
 
667
652
  ## Determinism check (optional confidence test)
668
653
 
669
- The pinned `datasetStart`/`datasetEnd` window plus seeded RNG produces near-bit-exact output across runs. To confirm no NEW non-determinism crept in (e.g. wall-clock leak in a hook):
654
+ For seeded generation, pin `datasetStart`/`datasetEnd` and `concurrency: 1`.
655
+ Use isolated sequential runs and strip only `insert_id` before comparing events:
670
656
 
671
657
  1. Run a previously-passing dungeon a second time.
672
- 2. Compare `eventCount` in the runner's JSON output should match within ~0.5%.
673
- 3. Re-run the hook's headline query and verify ratios match to 2 decimals.
658
+ 2. Require identical event counts, timestamps, ordering, and seeded property values.
659
+ 3. Require identical report output under the same explicit options.
674
660
 
675
- **Tolerance note**: most vertical dungeons produce bit-exact event counts across runs, but a few show <0.5% variance from RNG-state interactions. Variance at this scale does NOT affect hook signal direction or magnitude — all signals remain stable across runs. Treat <1% event-count drift as acceptable; investigate only if drift exceeds 1% OR a hook ratio swings meaningfully (>10% relative change between runs).
676
-
677
- If event count differs by >1% OR a hook ratio swings sharply, the hook has a fresh non-determinism source (typically `dayjs()`, `Date.now()`, `Math.random()`, or stale module-level state). Fix before continuing.
661
+ Investigate differences, including wall-clock calls, unseeded RNG, and stale
662
+ module state. Document known unseeded property functions separately; do not
663
+ accept a generic percentage drift as determinism proof.
678
664
 
679
665
  ## Critical time-window verification pattern
680
666
 
681
- Many dungeons use relative time windows (e.g., "spike on days 75-85"). The post-shift dataset start is exposed to hooks as `meta.datasetStart` (unix seconds). For DuckDB verification, use the same anchor:
667
+ Use the resolved dataset bounds, also exposed as `meta.datasetStart` and
668
+ `meta.datasetEnd` in unix seconds. Generation occurs inside this window without
669
+ a post-generation shift. Observed event extrema do not reconstruct configured
670
+ bounds, especially for sparse or partially observed windows:
682
671
 
683
672
  ```sql
684
- -- WRONG: uses MIN(time) which is up to 30 days BEFORE dataset start (pre-existing user spread)
685
- SELECT *, EXTRACT(EPOCH FROM (time::TIMESTAMP - (SELECT MIN(time::TIMESTAMP) FROM events))) / 86400 as day_in
686
- FROM events;
687
-
688
- -- RIGHT: anchor to MAX(time) - num_days, which is the post-shift dataset start
689
673
  WITH bounds AS (
690
- SELECT MAX(time::TIMESTAMP) - INTERVAL 'NUM_DAYS' day as datasetStart
691
- FROM events
674
+ SELECT TIMESTAMP '<RESOLVED_DATASET_START_UTC>' as datasetStart
692
675
  )
693
676
  SELECT *, EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.datasetStart)) / 86400 as day_in
694
677
  FROM events e, bounds b;
695
678
  ```
696
679
 
697
- Pre-existing users have events for up to 30 days BEFORE the dataset start (`preExistingSpread: 'uniform'` default in macro). MIN(time) reflects those pre-existing events, not the dataset window. Always anchor to MAX(time) - num_days for "day in dataset" calculations.
680
+ Pre-existing profile creation can precede the window; generated user events still
681
+ stay inside the resolved bounds. Never substitute `MAX(time) - numDays` for them.
698
682
 
699
683
  ## TTC hook verification — two approaches
700
684
 
701
685
  TTC hooks come in two forms. Use the matching verification approach:
702
686
 
703
- ### Approach 1: Property-Scaling TTC (preferred — produces NAILED verdicts)
687
+ ### Approach 1: Numeric timing-property report
704
688
 
705
689
  The hook scales a timing PROPERTY (e.g., `response_time_mins *= 0.67`) by segment. Verification is trivial:
706
690
 
@@ -713,49 +697,33 @@ WHERE event IN ('alert acknowledged', 'alert resolved')
713
697
  GROUP BY segment ORDER BY avg_response;
714
698
  ```
715
699
 
716
- This consistently produces exact matches to the hook factors (e.g., 0.67x target 0.665x measured).
717
-
718
- ### Approach 2: Timestamp-Shifting TTC (use when no timing property exists)
700
+ This proves a property aggregate only. Measure paired baseline/treatment and a
701
+ factor-one control; raw segment ratios can reflect different starting distributions.
719
702
 
720
- The hook shifts event timestamps in the everything hook using `scaleFunnelTTC()` or manual gap scaling. Verification requires a **bound-sequence query** — never use the lazy MIN→MIN proxy:
721
-
722
- ```sql
723
- -- WRONG: lazy MIN→MIN proxy (mixes events from different funnel passes)
724
- SELECT user_id, MIN(a.time) AS start, MIN(b.time) AS end ...
725
-
726
- -- RIGHT: bound-sequence (first A, then first B AFTER that A)
727
- WITH steps AS (
728
- SELECT user_id, event, time::TIMESTAMP AS t
729
- FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
730
- ),
731
- funnel AS (
732
- SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
733
- (SELECT MIN(t) FROM steps c
734
- WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
735
- FROM steps a WHERE a.event = 'step_a'
736
- ORDER BY a.user_id, a.t
737
- )
738
- SELECT segment,
739
- COUNT(*) AS users,
740
- ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
741
- FROM funnel JOIN users USING (user_id)
742
- WHERE end_t IS NOT NULL
743
- GROUP BY segment ORDER BY median_min;
744
- ```
703
+ ### Approach 2: Funnel timestamp TTC report
745
704
 
746
- The bound-sequence pattern finds the first A per user, then the first C strictly after that A. This matches how the everything hook operates and typically produces STRONG verdicts. The lazy MIN→MIN proxy produces flat or inverted results because it grabs unrelated events from different funnel passes.
705
+ Use steps-based `timeToConvert` on completed histories with the report's window,
706
+ order, filters, identity, and reentry options. A first-A/next-C query can skip
707
+ required B, miss restarts, or mishandle grace. Compare the same report on baseline,
708
+ treatment, and neutral-control streams. Keep mean and median claims separate.
747
709
 
748
710
  ### Which approach to recommend when writing hooks
749
711
 
750
- Property scaling is strictly better for verification. When creating new TTC hooks, always prefer scaling timing properties (see HOOKS.md principle #15). Reserve timestamp shifting for cases where no numeric timing property exists on the relevant events.
712
+ Choose the hook based on the intended report. Numeric property scaling answers
713
+ a property report; timestamp changes target funnel elapsed time. Ease of
714
+ verification does not authorize replacing one with the other.
751
715
 
752
716
  ### Legacy funnel-post TTC hooks
753
717
 
754
- If a dungeon still uses `funnel-post` for TTC (not yet migrated to `everything`), the effect is only visible in Mixpanel's funnel median TTC report, not in any SQL query. Mark as STRONG by code inspection and recommend migration to property scaling or everything-hook timestamp shifting.
718
+ Measure legacy `funnel-post` effects with the same report contract and controls.
719
+ Never assign STRONG by code inspection. If instance-level mutations fail to move
720
+ completed report histories, retain the miss and investigate competing instances.
755
721
 
756
722
  ## Magic-number cohort sizing — inspect distribution first
757
723
 
758
- Before checking inverted-U signal magnitude, confirm the cohort sizes are statistically meaningful (≥200 in sweet bucket). If cohort is too small, signal magnitude is irrelevant:
724
+ Before checking inverted-U signal magnitude, count independent eligible users in
725
+ each bucket against a predeclared population floor. A floor such as 200 is a
726
+ design choice, not universal statistical proof:
759
727
 
760
728
  ```sql
761
729
  SELECT pn, COUNT(*) FROM (
@@ -764,19 +732,17 @@ SELECT pn, COUNT(*) FROM (
764
732
  ) GROUP BY pn ORDER BY pn LIMIT 20;
765
733
  ```
766
734
 
767
- If 90%+ of users have 0-1 events of X, the dungeon's `sweet=4-7 / over=8+` ranges produce <50 users in sweet → no signal possible. Two fixes:
768
- 1. Bump `numUsers` 5x (cohort grows linearly with users; preserves story)
769
- 2. Recommend the dungeon author redefine ranges to match actual distribution (e.g. `sweet=2-5 / over=6+`)
770
-
771
- Choice depends on whether the JSDoc's stated ranges are load-bearing for the dungeon's narrative ("you need 8+ photos to seem fake" — preserve range, scale up users) or arbitrary ("sweet 4-7" can shift to "sweet 2-5" without losing the story).
735
+ If too few users reach the declared buckets, report insufficient evidence.
736
+ Request a larger run while preserving the story. Changing bucket ranges changes
737
+ the report specification and requires an explicit story revision and new proof.
772
738
 
773
739
  ## Re-run required after hook edits
774
740
 
775
741
  If you edit a hook then query the existing data files, you'll get STALE results. The verifier must re-run the dungeon AND wait for full completion before re-querying:
776
742
 
777
743
  ```bash
778
- rm -f ./data/verify-<NAME>-*
779
- node scripts/verify-runner.mjs dungeons/vertical/<NAME>.js verify-<NAME>
744
+ # Keep this run's files for verification and deployment; cleanup needs explicit consent.
745
+ node scripts/verify-runner.mjs dungeons/vertical/<NAME>/<NAME>.js verify-<NAME>-r2
780
746
  # Wait for the {"mode":"full","eventCount":...} JSON to print before querying
781
747
  ```
782
748
 
@@ -804,49 +770,26 @@ The `event` hook receives `meta.datasetStart` as a unix timestamp, but temporal
804
770
 
805
771
  ## Property baseline dilution
806
772
 
807
- When a hook overrides a property value (e.g., `event_type = "plan_upgraded"`), the effect is invisible if the baseline distribution already has a high rate of that value. Example: if `plan_upgraded` is 1 of 5 values (20% baseline), a 40% hook override produces ~28% observed — nearly invisible.
808
-
809
- **Fix:** skew the baseline distribution AWAY from the hook's target value. Make `plan_upgraded` 1 of 8+ values (12.5% baseline), then the 40% hook produces ~48% in the window a clear 4x spike.
810
-
811
- Similarly, if a hook forces `scale_direction = "down"` but the baseline is already 86% "down" (6:1 ratio in config), the hook is invisible. Change the baseline to favor "up" (e.g., 3:1 up:down) so the hook's forced "down" creates a measurable shift.
773
+ If an independent hook forces a value on fraction `q` of eligible events with
774
+ baseline prevalence `p`, expected prevalence is `q + (1 - q) * p`. For `p=0.20`
775
+ and `q=0.40`, that is 0.52. Other targeting and time-window rules need their own
776
+ derivation. Measure the neutral baseline; request an authorized schema change if
777
+ the baseline distribution must change. Do not tune it silently after a miss.
812
778
 
813
779
  ## Computing the dataset window
814
780
 
815
- Dungeons declare their time window in one of three ways — the verifier must derive the actual start/end before writing DuckDB queries:
816
-
817
- | Config shape | How to derive window |
818
- |---|---|
819
- | `datasetStart` + `datasetEnd` | Use directly |
820
- | `numDays` only (no explicit start/end) | `datasetEnd = NOW`, `datasetStart = NOW - numDays` |
821
- | `datasetStart` + `numDays` | `datasetEnd = datasetStart + numDays` |
822
-
823
- The engine always resolves to a `[datasetStart, datasetEnd]` pair internally (see `config-validator.js`). To find the actual window from the OUTPUT data:
824
-
825
- ```sql
826
- SELECT
827
- MAX(time::TIMESTAMP) as datasetEnd,
828
- MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as datasetStart
829
- FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1);
830
- ```
831
-
832
- Use `datasetStart` (derived above) as the DuckDB anchor for day-in-dataset:
833
-
834
- ```sql
835
- WITH bounds AS (
836
- SELECT MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as ds_start
837
- FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1)
838
- )
839
- SELECT EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.ds_start)) / 86400 as day_in
840
- FROM events e, bounds b;
841
- ```
842
-
843
- Do NOT use `MIN(time)` as the anchor — pre-existing users have events up to 30 days before `datasetStart` (from `preExistingSpread: 'uniform'`).
844
-
845
- When the dungeon has explicit `datasetStart` (e.g., `"2026-01-01T00:00:00Z"`), use it directly: `TIMESTAMP '2026-01-01'`. When `numDays` is used without explicit start, derive from MAX(time) as shown above.
781
+ Record the engine's resolved `datasetStart`/`datasetEnd` from the run, including
782
+ derived windows. Use those values in the query shown under "Critical time-window
783
+ verification pattern". If the artifacts do not retain bounds, report the missing
784
+ metadata or regenerate a separately named pinned run. Neither observed MIN/MAX
785
+ nor the current wall clock can recover the original resolved window reliably.
846
786
 
847
787
  ## No flag stamping audit
848
788
 
849
- Hooks must NEVER add cohort flags like `is_whale`, `power_user`, `sweet_spot`, `is_churned`, etc. All cohorts must be derived behaviorally from raw event data. When auditing a dungeon, check the hook for any property assignments that create boolean/categorical flags not defined in the original schema. If found, remove them and rewrite the hook to achieve the same effect through property value mutations, event filtering, or event injection.
789
+ Hooks must never add undeclared flags. A schema-declared flag with a default is
790
+ valid; hidden cohorts can use behavioral or hash definitions. Flag undeclared
791
+ assignments as SCHEMA-FAIL and request an authorized schema declaration or a hook
792
+ rewrite using existing fields. Uniform coverage does not make them acceptable.
850
793
 
851
794
  ## Clone dilution of temporal effects
852
795
 
@@ -865,7 +808,7 @@ If Hook A classifies users by event presence (`events.some(e => e.event === X)`)
865
808
  **Fixes:**
866
809
  1. Require 3+ marker events instead of 1+ (surviving events still identify)
867
810
  2. Accept the verification limitation and note it in the report
868
- 3. Use a metric that doesn't depend on cohort reconstruction (e.g., overall distribution shift instead of cohort comparison)
811
+ 3. Propose a separate distribution diagnostic while retaining the original cohort report's unresolved status; changing the report requires explicit revision.
869
812
 
870
813
  ## Deprecated feature property gaps
871
814
 
@@ -873,4 +816,6 @@ Dungeons using deprecated config blocks (`subscription`, `attribution`, `feature
873
816
 
874
817
  **Diagnosis:** Hook logic references a property that's always NULL/undefined in the output. Check if the property was produced by a deprecated feature.
875
818
 
876
- **Fix:** The dungeon author must add equivalent property generation in the hook itself (via `user` or `everything` hook) or add the property to `superProps`/`userProps` with appropriate values. This is a schema-level fix, not a verification fix — flag it in the report as "NONE: deprecated feature property missing" with the recommended fix.
819
+ **Fix:** Request a schema declaration with a default through `/create-dungeon`
820
+ before hooks assign values. Report the missing field and preserve the runner's
821
+ verdict; adding undeclared property generation inside a hook is not a valid fix.
@@ -0,0 +1,34 @@
1
+ # Warehouse Metric CRUD Gap Report
2
+
3
+ Dungeon: {{DUNGEON_PATH}}
4
+ Warehouse dir: {{WAREHOUSE_DIR}}
5
+ Dataset: {{DATASET}}
6
+ Source id: {{SOURCE_ID}}
7
+
8
+ Note: {{NOTE}}
9
+
10
+ ## Desired Powertools Contract
11
+
12
+ All under `/crud`, POST to execute, GET for docs, standard `client_id` / `region` body convention.
13
+
14
+ | Endpoint | Required | Notes |
15
+ |---|---|---|
16
+ | `createWarehouseMetric` | `project_id`, `source_id`, `name`, `sql`, `value_column` | Optional: `metric_type`, `time_column`, `aggregation`, `refresh`, `description` |
17
+ | `getWarehouseMetrics` | `project_id` | |
18
+ | `getWarehouseMetric` | `project_id`, `metric_id` | |
19
+ | `updateWarehouseMetric` | `project_id`, `metric_id`, `payload` | `source_id` is immutable; delete and recreate to rebind |
20
+ | `deleteWarehouseMetric` | `project_id`, `metric_id` | |
21
+ | `refreshWarehouseMetric` | `project_id`, `metric_id` | Cache invalidation only; does not execute the query |
22
+ | `previewWarehouseMetric` | `project_id`, `source_id`, `sql` | Returns rows; use before save because create does not validate SQL |
23
+
24
+ ## Manual Notes
25
+
26
+ - `value_column` is required for every warehouse metric, including numeric ones.
27
+ - `source_id` must come from the actual `/macro/setup-bq-warehouse` response.
28
+ - `previewWarehouseMetric` rejects raw SQL containing `DROP`, `DELETE`, `TRUNCATE`, `ALTER`, `CREATE`, `INSERT`, or `UPDATE` as substrings.
29
+ - `created_at` and `updated_at` therefore fail preview unless the blocked text is removed from the query entirely.
30
+ - `refreshWarehouseMetric` invalidates cache only; it does not execute the query.
31
+
32
+ ## Per-table Checklist
33
+
34
+ {{TABLE_CHECKLIST}}