Bundle 仕様 (schema 1.0)
Studio と Workspace の唯一の越境物。物理構造・manifest.json 完全スキーマ・Parquet 列定義・integrity 検証手順・canonical hash 計算・派生 (append_window etc) と PII 禁止フィールドを定義します。
Bundle の物理構造
Bundle は object storage 上の不変プレフィクスとして表現されます。本番は GCS、開発は MinIO (S3 互換)。
gs://{bucket}/bundles/{bundle_id}/
├── manifest.json 必須: バンドル全体のメタデータと整合性ハッシュ
├── _READY 必須: 登録完了マーカー (空ファイル)
├── windows/
│ ├── win_0001.parquet ECG 生波形 × Window 0 (全 lead)
│ ├── win_0002.parquet ECG 生波形 × Window 1
│ └── ...
├── attached/
│ ├── acc_win_0001.parquet 加速度 3 軸 (25 Hz, G 単位)
│ ├── opt_win_0001.parquet PPG 4ch (50 Hz, raw counts)
│ ├── bioz_win_0001.parquet BioZ mag/phase (1 Hz)
│ ├── vital_win_0001.parquet 皮膚温・電池・lead-off (1 Hz)
│ └── ...
├── derived/
│ ├── r_peaks.parquet R ピーク列 (全 Window 通し)
│ ├── rr_hrv.parquet RR 間隔 + rolling HRV
│ ├── invalid_intervals.parquet 無効区間 (体動・lead-off 等)
│ └── beat_labels.parquet 拍ラベル (v1 はスキーマのみ、行ゼロ)
└── pyramid/
├── L0/ 原信号チャンク (256 サンプル/チャンク)
│ └── win_0001.parquet
├── L1/ 250 ms min/max/mean
├── L2/ 1 s
└── L3/ 10 s 命名規則 (重要):
{bundle_id}はbdl_prefix 付き ULID (例:bdl_01HZMXXX...)。- Window ファイル名の連番は 1-based 4 桁ゼロ埋め (
win_0001,win_0002, …)。 - manifest の
windows[].ordinalは 0-based で、ファイル名win_{ordinal+1:04d}と 1 ずれる 点に注意。 _READYは空ファイル。Registry は manifest 検証と全 hash 検証が通った後にのみ書く。_READYが無い bundle はready扱いしてはならない。
manifest.json のトップレベルキー
UTF-8 JSON ファイル。15 個のトップレベルキーを持ちます。前方互換のため x- prefix の拡張のみ例外的に許可されます。
| キー | 型 | 必須 | 説明 |
|---|---|---|---|
schema_version | string | 必須 | ^\d+\.\d+$ 形式。現行 Phase 1: "1.0" |
bundle_id | string | 必須 | bdl_ prefix ULID |
tenant_id | string | 必須 | tnt_ prefix ULID |
subject | object | 必須 | §subject (canonical_key / public_id / cohort_tags) |
source | object | 必須 | §source (source_key / dataset / table / signal_family / sample_rate_hz / leads) |
windows | array | 必須 | §windows (要素数 ≥ 1) |
signals | object | 必須 | §signals (ecg / attached の Parquet 相対パス) |
derived | object | 必須 | §derived (r_peaks / rr_hrv / invalid_intervals / beat_labels) |
preprocessing | object | 必須 | §preprocessing (preset_id / hp_hz / lp_hz / notch_hz / detector / hrv_window_sec) |
quality | object | 必須 | §quality (invalid / wearing / leadoff ratio) |
integrity | object | 必須 | §integrity (files[] + manifest_sha256) |
lineage | object | 必須 | §lineage (parent / hashes / versions) |
privacy | object | 必須 | §privacy (pii_redaction_version / signed_url_ttl_sec / audit_event_ids) |
time_semantics | string | 必須 | 定数 "epoch_ms_half_open" (schema_version 1.x で不変) |
created_at | string | 必須 | ISO 8601 UTC (例: "2026-05-25T12:34:56.789Z") |
built_by | string | 任意 | usr_ prefix ULID。ジョブ起動ユーザ |
cost_usd | number | 任意 | BQ クエリの実コスト見積もり (USD) |
subject オブジェクト
raw uid・email・SA 認証情報は絶対に現れてはなりません。 (詳細は §PII 禁止)
canonical_key- 必須 · string (64 文字 hex) ·
HMAC-SHA256(tenant_secret, uid)の hex digest。PII-safe な不変識別子。canonical hash の入力としてsubject_canonical_keyに使われる。 public_id- 必須 · string ·
stable_hash(uid, tenant_salt)で算出したsub_prefix ULID。UI 表示・Registry API で使う。raw uid に逆算不可。 cohort_tags- 必須 · array of string · コホートタグ。空配列可。例:
["XHRO-04", "XH015"]
source オブジェクト
source_key- 必須 · allowlist 通過済みの BQ ソース識別子 (例:
"xhro_04.eeg") dataset- 必須 · BQ データセット名 (例:
"xhro_04") table- 必須 · BQ テーブル名 (例:
"eeg") signal_family- 必須 ·
"ecg"(Phase 1 固定)。将来:"eeg"/"ppg"など sample_rate_hz- 必須 · integer · 主信号サンプリング周波数 (Hz)。XHRO ECG:
250 leads- 必須 · array of string · 使用誘導リスト。例:
["ch1", "ch2"]
XHRO 注意: eeg テーブルには ECG 用 (ch1, ch2) と EEG 用 (ch3, ch4) が同居します。Phase 1 では leads に ["ch1", "ch2"] のみを入れ、EEG チャンネルは含めません。
windows 配列要素
各要素は 半開区間 [start_ms, end_ms) で定義された Window を表します。
ordinal- 必須 · integer · 0-based インデックス。ファイル名
win_{ordinal+1:04d}と 1 ずれる。 label- 任意 · string or null · ユーザ定義ラベル (例:
"rest-1")。canonical hash 計算には含まれない (ラベル違いの重複を防ぐ)。 start_ms- 必須 · integer · 区間開始 epoch ms (inclusive)
end_ms- 必須 · integer · 区間終了 epoch ms (exclusive)
time_semantics: "epoch_ms_half_open" は [start_ms, end_ms) が常に半開区間であることを示します。Parquet の ts_ms カラムも start_ms <= ts_ms < end_ms を満たします。隣接 Window でサンプル重複なし ([t0,t1) と [t1,t2) は排他)。
Parquet カラムスキーマ
各 Parquet ファイルの列定義。Parquet ファイルメタデータ (key_value_metadata) には sample_rate_hz / window_ordinal / bundle_id / signal_family を必ず付与します。
| ファイル | 内容 | カラム |
|---|---|---|
windows/win_NNNN.parquet | ECG 生波形 × Window N | uid_canonical / ts_ms / lead / value_uv |
attached/acc_win_NNNN.parquet | 加速度 3 軸 (25 Hz, G 単位) | ts_ms / x / y / z |
attached/opt_win_NNNN.parquet | PPG 4ch (50 Hz, raw counts) | ts_ms / ch1_ir / ch2_r / ch3_g / ch4_b |
attached/bioz_win_NNNN.parquet | BioZ mag/phase (1 Hz) | ts_ms / mag (Ω) / phase (rad) |
attached/vital_win_NNNN.parquet | 皮膚温・電池・lead-off (1 Hz) | ts_ms / temp_c / fg / loff_ch1..4 |
derived/r_peaks.parquet | R ピーク列 (全 Window 通し) | window_id / sample_idx / ts_ms / lead / confidence |
derived/rr_hrv.parquet | RR 間隔 + rolling HRV | window_id / t_ms / rr_ms / sdnn / rmssd / pnn50 / lf / hf |
derived/invalid_intervals.parquet | 無効区間 (体動・lead-off 等) | window_id / start_ms / end_ms / reason |
derived/beat_labels.parquet | 拍ラベル (v1 はスキーマのみ、行ゼロ) | window_id / sample_idx / ts_ms / lead / label / annotator / confidence |
pyramid/L0/win_NNNN.parquet | 原信号チャンク (256 sample/chunk) | window_id / chunk_idx / samples (LIST[DOUBLE]) |
pyramid/L{1,2,3}/*.parquet | min/max/mean 集約 (250ms / 1s / 10s) | window_id / bin_idx / min / max / mean |
integrity オブジェクト
Bundle の完全性検証用。files 配列には manifest.json 以外の全ファイル (windows/, attached/, derived/, pyramid/, _READY) のエントリが並びます。
"integrity": {
"files": [
{
"rel_path": "windows/win_0001.parquet",
"bytes": 1245678,
"sha256": "a1b2c3d4e5f6...64chars"
},
{
"rel_path": "_READY",
"bytes": 0,
"sha256": "e3b0c44298fc...SHA256 of empty"
}
// ... 全ファイル分
],
"manifest_sha256": "0f1e2d3c4b5a...64chars"
} manifest_sha256 の計算手順
manifest.json 自身の内容から計算されます。循環参照を避けるため、integrity.manifest_sha256 フィールド自体を除外して計算します。
- manifest dict を組み立て、
integrity.manifest_sha256キーをnullまたは省略した状態にする json.dumps(manifest_dict, sort_keys=True, separators=(",", ":"))でシリアライズ- その UTF-8 バイト列の SHA-256 hex digest を
manifest_sha256として格納 - manifest.json として書き出す
Workspace 側の検証コード (4 step)
# Step 1: manifest.json をパース
import json, hashlib, os, copy
with open("manifest.json", "rb") as f:
raw_bytes = f.read()
manifest = json.loads(raw_bytes)
# schema_version 確認 (§schema-version)
# Step 2: 各ファイルの sha256 + bytes を検証
for entry in manifest["integrity"]["files"]:
rel_path = entry["rel_path"]
with open(rel_path, "rb") as f:
data = f.read()
assert hashlib.sha256(data).hexdigest() == entry["sha256"]
assert len(data) == entry["bytes"]
# Step 3: _READY マーカーの存在
assert os.path.exists("_READY"), "Bundle is not committed (_READY missing)"
# Step 4: manifest_sha256 の検証 (推奨)
m = copy.deepcopy(manifest)
saved_hash = m["integrity"].pop("manifest_sha256", None)
serialised = json.dumps(m, sort_keys=True, separators=(",", ":")).encode()
assert hashlib.sha256(serialised).hexdigest() == saved_hash lineage オブジェクト
Bundle の系譜情報。Registry の bundles.canonical_hash と一致する canonical_hash を含みます。
parent_bundle_id- 任意 · string or null · 派生元 Bundle ID
derive_kind- 任意 · string or null ·
"append_window"/"re_preset"/"attach_signal" bq_query_hash- 必須 · BQ SQL を normalize して SHA-256 した hex digest
dry_run_id- 任意 · 対応する dry-run ジョブの ID
code_version- 必須 ·
ecg-coreパッケージバージョン。例:"ecg-core@0.1.0" build_version- 必須 ·
bundle-buildパッケージバージョン template_version- 必須 · BQ SQL テンプレバージョン。例:
"window_extract@0.1.0" settings_snapshot_hash- 必須 · 解決済み設定値の SHA-256
resolved_plan_hash- 必須 ·
ResolvedBuildPlanの canonical_hash canonical_hash- 必須 · idempotency キー。Registry の
bundles.canonical_hashと一致
canonical_hash 計算の正規化手順
canonical.py::resolved_plan_hash(plan) が計算します。入力は ResolvedBuildPlan.model_dump() で取得した dict。計算前に以下の正規化を行います。
windows_canonicalをstart_msで昇順ソートattached_signalsをアルファベット昇順ソートpreset_params.leadsをアルファベット昇順ソートderived_feature_flagsをキーでアルファベット昇順ソート
その後 json.dumps(normalised, sort_keys=True, separators=(",", ":"), default=str) でシリアライズし、UTF-8 バイト列の SHA-256 hex digest とします。
ResolvedBuildPlan には以下が含まれます: source_key, subject_canonical_key (HMAC、生 uid 不可), windows_canonical, attached_signals, preset_id, preset_params (hp/lp/notch/detector/rr_min/rr_max/hrv_window/leads), ecg_core_version, bundle_build_version, sql_template_version, schema_version, settings_snapshot_hash, derived_feature_flags。
派生 Bundle (常に新 ID を発行)
Bundle は 完全 immutable。Window 追加・preset 変更・付帯信号追加は どれも新 bundle_id の発行 で表現し、親側の manifest は一切書き換えません。「同一 Bundle の追記版」という表現は使いません (用語の混乱を避けるため)。
append_window
同一 source / subject / preset で Window を追加した子 Bundle。例: bdl_v1 に夜間 Window を 3 本追加して bdl_v1_append_window を作る。
re_preset
同じ Window 群に別プリセットをかけた子 Bundle。例: holter-overnight → noisy-environment。
attach_signal
同じ Window に新しい付帯信号を加えた子 Bundle。例: ACC + PPG だった親に BioZ を追加。
- 子 Bundle の manifest には
parent_bundle_idとderive_kindを必ず記録 (lineage.parent)。 - 親 Bundle は子の存在を 知らない (親は閉じている。系譜は Registry の
bundle_lineageビューで辿る)。 - 削除: 物理削除は admin のみ。通常は
archivedフラグ。被験者 erasure 時のカスケード規則は別途定義。
stale 判定
Workspace の evidence は Bundle ID をキャッシュキーに含むため、親 Bundle を直接書き換える操作はありません。stale が起きるのは:
- Project に紐付く Bundle 集合 (
linked_bundles) を 新 ID に差し替えた 時 (例:bdl_v1→bdl_v1_re_preset) - 既存 Bundle 集合に 新派生 Bundle を追加 した時 (例:
append_windowでbdl_v2を追加)
stale(evidence) := evidence.bundle_set_hash != current_project.bundle_set_hash
OR any(b in evidence.bundles where b.archived == true) stale は 再計算しません (FR-W9)。UI には黄色バッジで「依存 Bundle が更新されています — 再実行」ボタンを出すのみ。Window 単位の部分 stale は v1 ではサポートしません (Project = Bundle 集合 単位での粒度に揃える)。
schema_version マイグレーションポリシー
MAJOR バンプ (例: 1.x → 2.0)
- manifest の後方互換を壊す変更: トップレベルキーの削除、型変更、必須フィールドの意味変更。
- 既存 Bundle に対する再建構 (re-build) または
status = "archived_legacy"への遷移が必要。 - Workspace は
schema_versionの MAJOR が未知であればエラーを発生させ、読み取りを拒否する。
MINOR バンプ (例: 1.0 → 1.1)
- 加法的変更のみ: 新しい任意フィールドの追加、
qualityやprivacyの拡張。 - 旧 Bundle を再ビルドする必要はない。
- Workspace は 知らないフィールドを無視 し、既知のフィールドだけを読む (Forward-compatible)。
パッチバンプは存在しません。schema_version は MAJOR.MINOR の 2 段のみ (^\d+\.\d+$)。
Workspace の unknown-field 扱い (必須ルール)
- Strict ではなく Partial パース:
BundleManifestの Pydantic モデルはmodel_config = ConfigDict(extra="ignore")またはextra="allow"を使う。extra="forbid"を設定してはならない。 - ラウンドトリップ保存: Workspace が manifest を読み込んで再書き込みする場合、読み取り時に無視したフィールドも保存し直す。Pydantic の
model_dump(exclude_unset=False)+ 元の raw dict をマージする方式が安全。 - unknown-field の記録: Workspace ログに MINOR 不一致で警告を出すことを推奨するが、エラーにしてはならない。
PII 禁止フィールド
以下のデータは Bundle の いかなる場所にも 平文で出現してはなりません。これはドキュメントポリシーであると同時に、CI の test_pii_grep で自動検証されます。
| 場所 | 禁止データ | 正しい代替 |
|---|---|---|
| manifest.json (subject) | Firebase Auth raw uid (28 文字英数字) | subject.canonical_key (HMAC) または subject.public_id |
| manifest.json (subject) | 運用メール (u004@xhro.org 等) | 含めない |
| manifest.json (任意) | SA 秘密鍵 JSON (.keys/ 配下) | 含めない |
| manifest.json (任意) | 署名付き URL (?X-Goog-Signature=...) | 含めない |
| lineage | lineage に raw uid を使う | subject_canonical_key は HMAC であること |
| windows/win_*.parquet | uid_canonical カラムに Firebase raw uid | HMAC または public_id を使う |
| attached/* | BQ の uid カラムを Parquet に出力 | uid カラムは含めない |
| derived/* | uid カラム | 禁止 |
| Parquet key_value_metadata | raw uid / email / SA credentials | 含めない |
| bundle_audit.payload / audit_log.payload | raw uid / email / signed URL | 含めない |
CI grep テストの対象
- manifest.json (全 Bundle)
- Parquet ファイルメタデータ
- Parquet の文字列カラム値
- Copilot 出力ストリーム
- audit_log.payload
- SSE progress events
manifest.json 完全例
5 分 × 2 Window の ECG-only Bundle に ACC を付帯させた Phase 1 典型例です。JSON コメントは説明用で、実際の JSON には含まれません。
{
"schema_version": "1.0",
"bundle_id": "bdl_01HZMX9P2QR3S4T5U6V7W8X9Y",
"tenant_id": "tnt_01HZMX0000000000000000001",
"time_semantics": "epoch_ms_half_open",
"created_at": "2026-05-25T12:34:56.789Z",
"built_by": "usr_01HZMX0000000000000000099",
"cost_usd": 0.0023,
"subject": {
"canonical_key": "a3f8c2e1d4b9071256f3e8c4a1d9b27e5f6c3a2b1d8e4f7c9b0a3d2e1f5c8b6a",
"public_id": "sub_01HZMX9ABCDE00000000XH015",
"cohort_tags": ["XHRO-04", "XH015"]
},
"source": {
"source_key": "xhro_04.eeg",
"dataset": "xhro_04",
"table": "eeg",
"signal_family": "ecg",
"sample_rate_hz": 250,
"leads": ["ch1", "ch2"]
},
"windows": [
{ "ordinal": 0, "label": "rest-1",
"start_ms": 1699578000000, "end_ms": 1699578300000 },
{ "ordinal": 1, "label": "rest-2",
"start_ms": 1699578300000, "end_ms": 1699578600000 }
],
"signals": {
"ecg": {
"windows": [
{ "ordinal": 0, "rel_path": "windows/win_0001.parquet" },
{ "ordinal": 1, "rel_path": "windows/win_0002.parquet" }
]
},
"attached": {
"acc": [
{ "ordinal": 0, "rel_path": "attached/acc_win_0001.parquet" },
{ "ordinal": 1, "rel_path": "attached/acc_win_0002.parquet" }
]
}
},
"derived": {
"r_peaks": {
"rel_path": "derived/r_peaks.parquet",
"algorithm": "pan_tompkins_v1",
"algorithm_version": "ecg-core@0.1.0"
},
"rr_hrv": {
"rel_path": "derived/rr_hrv.parquet",
"hrv_window_sec": 300
},
"invalid_intervals": { "rel_path": "derived/invalid_intervals.parquet" },
"beat_labels": { "rel_path": "derived/beat_labels.parquet", "schema": "mit_bih_v1" }
},
"preprocessing": {
"preset_id": "xhro-default",
"hp_hz": 0.5, "lp_hz": 40.0, "notch_hz": 50,
"detector": "pan_tompkins_v1",
"detector_params": { "rr_min_ms": 200.0, "rr_max_ms": 2000.0 },
"hrv_window_sec": 300
},
"quality": {
"invalid_intervals_count": 3,
"invalid_ratio": 0.012,
"wearing_ratio": 0.997,
"leadoff_ratio": 0.003
},
"integrity": {
"files": [
{ "rel_path": "windows/win_0001.parquet", "bytes": 1245678, "sha256": "a1b2..." },
{ "rel_path": "windows/win_0002.parquet", "bytes": 1243001, "sha256": "b2c3..." },
{ "rel_path": "attached/acc_win_0001.parquet", "bytes": 18432, "sha256": "c3d4..." },
{ "rel_path": "derived/r_peaks.parquet", "bytes": 4096, "sha256": "e5f6..." },
{ "rel_path": "_READY", "bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }
],
"manifest_sha256": "0f1e2d3c4b5a..."
},
"lineage": {
"parent_bundle_id": null,
"derive_kind": null,
"bq_query_hash": "9a8b7c6d5e4f...",
"dry_run_id": "job_01HZMXDRYRUN000000000001",
"code_version": "ecg-core@0.1.0",
"build_version": "bundle-build@0.1.0",
"template_version": "window_extract@0.1.0",
"settings_snapshot_hash": "1234abcd5678...",
"resolved_plan_hash": "fedcba987654...",
"canonical_hash": "0123456789ab..."
},
"privacy": {
"pii_redaction_version": "v1",
"signed_url_ttl_sec": 900,
"audit_event_ids": ["1234567", "1234568"]
}
} 本仕様 vs 実装の不整合 (要フォローアップ)
本ドキュメント作成時点で実装コードとの差異として確認された不整合の一覧です。実装を本仕様に合わせる PR が必要です (Workspace 側は前方互換ルール (§schema-version) により欠損カラムを NaN で扱えば動作します)。
| 場所 | 現実装の状態 | 本仕様の要求 |
|---|---|---|
| manifest.py l.57 / ManifestFileEntry.path | integrity.files[].path キー名を使用 | 本仕様では rel_path。Pydantic モデルか本仕様のどちらかを揃える必要あり |
| derived.py::r_peaks_to_bytes の ts_ms | Window 先頭からのオフセット ms の可能性 (コメント不足) | epoch ms の絶対値であるべき。検証テストで固定する |
| derived.py::rr_hrv_to_bytes | sdnn rmssd pnn50 lf hf カラムを出力しない | 本仕様はこれら 5 カラムを定義。Workspace は欠損時 NaN として扱う (前方互換) |
| derived.py::invalid_intervals_to_bytes の reason | "artifact" 固定 | 本仕様は "acc_motion" / "leadoff" / "manual" を将来値として定義 |
| derived.py::beat_labels_to_bytes | ts_ms lead annotator confidence カラムなし | 本仕様はこれら 4 カラムを v1 スキーマとして定義 |
| pyramid.py::pyramid_to_bytes | bucket_start_ms カラムなし。bin_idx のみ | 本仕様は bucket_start_ms を将来追加予定 (現状は bin_idx * bin_width_ms + window.start_ms で自力計算) |
| BundleManifest (studio_api/schemas/bundle.py) | tenant_id / subject / quality / privacy フィールドなし | 本仕様のトップレベルキーと不一致。M0 スキーマ固定作業で解消 |
| packages/schemas/src/schemas/bundle.py | bundle_id と schema_version のみ (ほぼ空) | 正本スキーマを apps/studio-api/... に一本化し、packages/schemas は thin re-export |