@jaguilar87/gaia 5.0.8 → 5.0.10

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 (108) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +10 -3
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/approvals.py +341 -238
  7. package/bin/cli/brief.py +45 -4
  8. package/bin/cli/cleanup.py +304 -4
  9. package/bin/cli/doctor.py +1 -5
  10. package/bin/cli/uninstall.py +20 -0
  11. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  12. package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
  13. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  14. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  15. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  16. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  17. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  18. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  19. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  20. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  21. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +434 -1
  22. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  23. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  24. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
  25. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  26. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  27. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  28. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  29. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  30. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  31. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  32. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  33. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  34. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  35. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  36. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  37. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  38. package/dist/gaia-ops/tools/migration/README.md +10 -12
  39. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  40. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  43. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  44. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  45. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  46. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  47. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  48. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  49. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  50. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  51. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +434 -1
  52. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  53. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  54. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
  55. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  56. package/gaia/approvals/store.py +87 -9
  57. package/gaia/briefs/__init__.py +4 -0
  58. package/gaia/briefs/store.py +91 -0
  59. package/gaia/store/schema.sql +38 -1
  60. package/gaia/store/writer.py +400 -0
  61. package/hooks/adapters/claude_code.py +19 -85
  62. package/hooks/elicitation_result.py +20 -75
  63. package/hooks/modules/context/context_injector.py +23 -7
  64. package/hooks/modules/core/plugin_setup.py +0 -5
  65. package/hooks/modules/events/event_writer.py +63 -96
  66. package/hooks/modules/security/__init__.py +0 -2
  67. package/hooks/modules/security/approval_cleanup.py +238 -69
  68. package/hooks/modules/security/approval_grants.py +506 -1103
  69. package/hooks/modules/security/capability_classes.py +83 -6
  70. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  71. package/hooks/modules/security/mutative_verbs.py +434 -1
  72. package/hooks/modules/session/pending_scanner.py +150 -90
  73. package/hooks/modules/session/session_manifest.py +257 -28
  74. package/hooks/modules/tools/bash_validator.py +177 -20
  75. package/hooks/post_compact.py +1 -0
  76. package/hooks/pre_compact.py +1 -0
  77. package/hooks/user_prompt_submit.py +20 -0
  78. package/package.json +1 -1
  79. package/pyproject.toml +20 -1
  80. package/scripts/bootstrap_database.sh +66 -17
  81. package/scripts/migrations/README.md +26 -14
  82. package/scripts/migrations/schema.checksum +2 -2
  83. package/scripts/migrations/v18_to_v19.sql +36 -0
  84. package/scripts/migrations/v19_to_v20.sql +20 -0
  85. package/skills/agent-approval-protocol/SKILL.md +27 -7
  86. package/skills/agent-approval-protocol/reference.md +11 -6
  87. package/skills/gaia-patterns/reference.md +2 -2
  88. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  89. package/skills/orchestrator-present-approval/reference.md +16 -3
  90. package/skills/orchestrator-present-approval/template.md +10 -5
  91. package/skills/pending-approvals/SKILL.md +16 -11
  92. package/skills/security-tiers/SKILL.md +1 -1
  93. package/skills/subagent-request-approval/SKILL.md +20 -6
  94. package/skills/subagent-request-approval/reference.md +23 -15
  95. package/tools/migration/README.md +10 -12
  96. package/tools/scan/orchestrator.py +194 -10
  97. package/tools/scan/tests/test_integration.py +1 -2
  98. package/bin/cli/plans.py +0 -517
  99. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  100. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  101. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  102. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  103. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  104. package/tools/context/deep_merge.py +0 -159
  105. package/tools/migration/migrate_04_harness_events.py +0 -132
  106. package/tools/migration/migrate_04_harness_events.sh +0 -23
  107. package/tools/scan/merge.py +0 -213
  108. package/tools/scan/tests/test_merge.py +0 -269
@@ -911,6 +911,57 @@ class BashValidator:
911
911
  reason="Safe by elimination (not blocked, not mutative)",
912
912
  )
913
913
 
914
+ def _is_ungranted_t3_component(
915
+ self, component: str, session_id: str
916
+ ) -> bool:
917
+ """Classify a chain component as ungranted-T3 WITHOUT minting or consuming.
918
+
919
+ Returns True when the component is a T3 (mutative-verb or
920
+ flag-dependent) operation for which NO active grant exists -- i.e. the
921
+ component would, on its own, be blocked pending approval. This is a
922
+ read-only probe used by the chain COMMAND_SET intake (AC-8) to decide
923
+ whether >= 2 sub-commands need grouping under ONE consent, BEFORE any
924
+ per-component minting happens.
925
+
926
+ It deliberately does NOT call decide_t3_outcome (no pending minted) and
927
+ does NOT consume any grant (match_command_set_grant /
928
+ check_approval_grant are pure lookups; consumption happens later in the
929
+ real _validate_single_command pass at retry). A component that already
930
+ matches a COMMAND_SET or semantic grant is treated as NOT ungranted, so
931
+ it is excluded from a fresh batch.
932
+ """
933
+ component = component.strip()
934
+ if not component:
935
+ return False
936
+
937
+ # Is this T3 (mutative verb or flag-dependent mutation)?
938
+ detect = detect_mutative_command(component)
939
+ is_t3 = detect.is_mutative
940
+ if not is_t3:
941
+ flag_result = classify_by_flags(component)
942
+ if (
943
+ flag_result is not None
944
+ and flag_result.outcome == FLAG_MUTATIVE
945
+ and not flag_result.command_family.startswith("git_")
946
+ ):
947
+ is_t3 = True
948
+ if not is_t3:
949
+ return False
950
+
951
+ # Already covered by an active grant? Then it is NOT ungranted -- exclude
952
+ # it from a fresh batch (pure lookups, no consumption).
953
+ try:
954
+ if match_command_set_grant(component) is not None:
955
+ return False
956
+ except Exception:
957
+ pass
958
+ try:
959
+ if check_approval_grant(component, session_id=session_id) is not None:
960
+ return False
961
+ except Exception:
962
+ pass
963
+ return True
964
+
914
965
  def _validate_compound_command(
915
966
  self,
916
967
  components: List[str],
@@ -918,9 +969,68 @@ class BashValidator:
918
969
  session_id: str = "",
919
970
  agent_type: str = "",
920
971
  ) -> BashValidationResult:
921
- """Validate a compound command (multiple components)."""
972
+ """Validate a compound command (multiple components).
973
+
974
+ Chain COMMAND_SET intake (AC-8): when a chain ``a && b && c`` has TWO OR
975
+ MORE sub-commands that are ungranted T3, classifying them one-at-a-time
976
+ mints a single-signature pending for the FIRST and short-circuits -- so
977
+ one approval covers only the first sub-command and the next re-blocks
978
+ (the double-approval the user hit). To group them, a NON-MINTING
979
+ classification pass runs FIRST (``_is_ungranted_t3_component``); if >= 2
980
+ sub-commands are ungranted-T3 (and we are a subagent under the
981
+ orchestrator), ONE COMMAND_SET pending is minted over exactly those T3
982
+ sub-commands via ``decide_t3_outcome(command_set=...)``. One approval
983
+ then covers the chain; each sub-command is still consumed byte-for-byte
984
+ by its own signature at retry (no consent is widened -- the commands are
985
+ only grouped). Critically, the per-component minting path
986
+ (_validate_single_command) is NEVER entered for the batch, so no stray
987
+ single pendings are minted alongside the COMMAND_SET.
988
+
989
+ For every other shape (0 or 1 ungranted-T3, no orchestrator above, or a
990
+ component that is hard-blocked) the original per-component pass runs
991
+ unchanged: a hard block fails the chain fast, a lone T3 keeps the
992
+ singular grant path, and an all-granted/safe chain is allowed.
993
+ """
922
994
  logger.info(f"Compound command detected with {len(components)} components")
923
995
 
996
+ # NON-MINTING pre-pass: which components are ungranted T3? (AC-8)
997
+ if is_subagent and is_ops_mode():
998
+ ungranted_t3_idx = [
999
+ idx
1000
+ for idx, comp in enumerate(components)
1001
+ if self._is_ungranted_t3_component(comp, session_id)
1002
+ ]
1003
+ if len(ungranted_t3_idx) >= 2:
1004
+ chain_set = [
1005
+ {"command": components[idx].strip(), "rationale": ""}
1006
+ for idx in ungranted_t3_idx
1007
+ ]
1008
+ first_cmd = chain_set[0]["command"]
1009
+ first_detect = detect_mutative_command(first_cmd)
1010
+ verb = first_detect.verb or "command"
1011
+ category = first_detect.category or "MUTATIVE"
1012
+ native_ask_reason = (
1013
+ f"[T3_APPROVAL_REQUIRED] Chain of {len(chain_set)} T3 commands.\n"
1014
+ f"Commands:\n"
1015
+ + "\n".join(f" - {it['command']}" for it in chain_set)
1016
+ )
1017
+ logger.info(
1018
+ "Chain COMMAND_SET intake: %d T3 sub-commands grouped under "
1019
+ "one consent (chain=%s)",
1020
+ len(chain_set),
1021
+ " && ".join(it["command"][:30] for it in chain_set),
1022
+ )
1023
+ return decide_t3_outcome(
1024
+ first_cmd,
1025
+ verb=verb,
1026
+ category=category,
1027
+ has_orchestrator_above=True,
1028
+ native_ask_reason=native_ask_reason,
1029
+ session_id=session_id,
1030
+ agent_type=agent_type,
1031
+ command_set=chain_set,
1032
+ )
1033
+
924
1034
  component_results: List[BashValidationResult] = []
925
1035
  for i, component in enumerate(components, 1):
926
1036
  result = self._validate_single_command(
@@ -1385,6 +1495,7 @@ def decide_t3_outcome(
1385
1495
  native_ask_reason: str,
1386
1496
  session_id: str = "",
1387
1497
  agent_type: str = "",
1498
+ command_set: list | None = None,
1388
1499
  ) -> BashValidationResult:
1389
1500
  """Single decision point for the outcome of a T3 (state-mutating) command.
1390
1501
 
@@ -1416,34 +1527,68 @@ def decide_t3_outcome(
1416
1527
  native_ask_reason: Reason text for the native-ask fallback branch.
1417
1528
  session_id: Session ID for pending-approval scoping.
1418
1529
  agent_type: Originating agent name (for the sealed payload).
1530
+ command_set: Optional list of ``{command, rationale}`` dicts. When it
1531
+ carries MORE THAN ONE item, this T3 decision covers a chain
1532
+ (``a && b && c``) whose sub-commands are all T3, and the pending is
1533
+ minted as ONE COMMAND_SET envelope (the chain-intake path, AC-8)
1534
+ instead of a single semantic-signature pending. ONE user approval
1535
+ then covers the whole chain; each sub-command is still consumed
1536
+ byte-for-byte by its own signature at retry. A None / single-item
1537
+ set keeps the singular behaviour. Only honoured in the
1538
+ subagent-under-orchestrator branch (the native-ask branch has no
1539
+ COMMAND_SET concept).
1419
1540
 
1420
1541
  Returns:
1421
1542
  A blocked BashValidationResult (allowed=False, tier T3) whose
1422
1543
  block_response is either a "deny" (with approval_id) or an "ask".
1423
1544
  """
1545
+ # A genuine multi-command chain is a set of >= 2 items. Anything else
1546
+ # collapses to the singular path so we never mint a COMMAND_SET for one
1547
+ # command (mirrors _build_sealed_payload's is_command_set guard).
1548
+ _normalized_set: list = []
1549
+ if command_set:
1550
+ for _item in command_set:
1551
+ if isinstance(_item, dict) and _item.get("command"):
1552
+ _normalized_set.append(
1553
+ {
1554
+ "command": _item["command"],
1555
+ "rationale": _item.get("rationale", ""),
1556
+ }
1557
+ )
1558
+ is_chain_command_set = len(_normalized_set) > 1
1559
+
1424
1560
  if has_orchestrator_above:
1425
1561
  # Subagent-under-orchestrator: deny + persisted approval_id so the
1426
1562
  # orchestrator can run the approval cycle. Reuse an existing pending
1427
1563
  # approval on retry to avoid generating duplicates while the user reviews.
1428
- approval_id = _find_pending_in_db(session_id or "", command)
1429
- if approval_id:
1430
- logger.info(
1431
- "Reusing pending approval_id=%s for retry: %s",
1432
- approval_id, command[:80],
1433
- )
1434
- reason = build_t3_blocked_denial_message(
1435
- approval_id=approval_id,
1436
- command=command,
1437
- verb=verb,
1438
- category=category,
1439
- )
1440
- hook_deny = build_hook_permission_response("deny", reason)
1441
- return BashValidationResult(
1442
- allowed=False,
1443
- tier=SecurityTier.T3_BLOCKED,
1444
- reason=f"T3 {category.lower()} command: {command[:60]}",
1445
- block_response=hook_deny,
1446
- )
1564
+ #
1565
+ # For a COMMAND_SET chain the pending id is CONTENT-derived (matching the
1566
+ # plan-first intake), so a retry of the same chain produces the same id
1567
+ # and the fingerprint-dedup in insert_requested reuses the pending. The
1568
+ # singular reuse probe (_find_pending_in_db) matches a SINGLE command's
1569
+ # signature and must NOT be consulted for the chain -- it would match one
1570
+ # leftover single pending of a sub-command and degrade the chain back to
1571
+ # a single grant. So the chain path skips it entirely.
1572
+ if not is_chain_command_set:
1573
+ approval_id = _find_pending_in_db(session_id or "", command)
1574
+ if approval_id:
1575
+ logger.info(
1576
+ "Reusing pending approval_id=%s for retry: %s",
1577
+ approval_id, command[:80],
1578
+ )
1579
+ reason = build_t3_blocked_denial_message(
1580
+ approval_id=approval_id,
1581
+ command=command,
1582
+ verb=verb,
1583
+ category=category,
1584
+ )
1585
+ hook_deny = build_hook_permission_response("deny", reason)
1586
+ return BashValidationResult(
1587
+ allowed=False,
1588
+ tier=SecurityTier.T3_BLOCKED,
1589
+ reason=f"T3 {category.lower()} command: {command[:60]}",
1590
+ block_response=hook_deny,
1591
+ )
1447
1592
 
1448
1593
  # No existing pending -- insert via DB (D16: exclusive path).
1449
1594
  sealed_payload = _build_sealed_payload(
@@ -1451,13 +1596,25 @@ def decide_t3_outcome(
1451
1596
  verb=verb,
1452
1597
  category=category,
1453
1598
  agent_type=agent_type,
1599
+ command_set=_normalized_set if is_chain_command_set else None,
1454
1600
  )
1455
1601
  try:
1456
1602
  from gaia.approvals.store import insert_requested
1603
+ # COMMAND_SET chains use a CONTENT-derived id (deterministic over the
1604
+ # sub-command list) so a retry of the same chain reproduces the same
1605
+ # id and reuses the pending via fingerprint dedup -- identical to the
1606
+ # plan-first intake in handoff_persister. Singular T3 keeps uuid4.
1607
+ supplied_id = None
1608
+ if is_chain_command_set:
1609
+ from gaia.approvals.store import derive_command_set_id
1610
+ supplied_id = derive_command_set_id(
1611
+ [it["command"] for it in _normalized_set]
1612
+ )
1457
1613
  approval_id = insert_requested(
1458
1614
  sealed_payload,
1459
1615
  agent_id=agent_type or None,
1460
1616
  session_id=session_id or None,
1617
+ approval_id=supplied_id,
1461
1618
  )
1462
1619
  except Exception as _store_err:
1463
1620
  logger.warning(
@@ -35,6 +35,7 @@ def _handle_post_compact(event) -> None:
35
35
 
36
36
  response = {
37
37
  "hookSpecificOutput": {
38
+ "hookEventName": "PostCompact",
38
39
  "additionalContext": context,
39
40
  }
40
41
  }
@@ -52,6 +52,7 @@ def _handle_pre_compact(event) -> None:
52
52
 
53
53
  response = {
54
54
  "hookSpecificOutput": {
55
+ "hookEventName": "PreCompact",
55
56
  "additionalContext": context,
56
57
  }
57
58
  }
@@ -194,6 +194,26 @@ if __name__ == "__main__":
194
194
  else:
195
195
  logger.info("Could not extract user prompt from stdin, skipping routing")
196
196
 
197
+ # Per-turn VERIFIED pending approvals. Lets the orchestrator present
198
+ # a pending approval for consent directly from injected context,
199
+ # WITHOUT dispatching a subagent to derive/verify it (that dispatch's
200
+ # SubagentStop caused a pending-revocation bug). Emits "" when there
201
+ # are no verified pendings, so a turn with nothing pending injects
202
+ # nothing -- this is what keeps the per-turn injection quiet, unlike
203
+ # the one-shot SessionStart summary it deliberately does not re-emit.
204
+ try:
205
+ from modules.session.session_manifest import (
206
+ build_per_turn_pending_approvals_block,
207
+ )
208
+ pending_block = build_per_turn_pending_approvals_block()
209
+ if pending_block:
210
+ context_parts.append(pending_block)
211
+ except Exception as _pa_exc:
212
+ logger.debug(
213
+ "per-turn pending approvals injection failed (non-fatal): %s",
214
+ _pa_exc,
215
+ )
216
+
197
217
  additional_context = "\n\n".join(context_parts)
198
218
  logger.info("Context injected: %s mode (%d chars)", mode, len(additional_context))
199
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.0.8",
3
+ "version": "5.0.10",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gaia"
3
- version = "5.0.8"
3
+ version = "5.0.10"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -12,7 +12,9 @@ readme = "README.md"
12
12
  [project.optional-dependencies]
13
13
  dev = [
14
14
  "pytest==8.3.5",
15
+ "pytest-cov>=5.0.0",
15
16
  "ruff>=0.4.0",
17
+ "pyyaml>=6.0",
16
18
  ]
17
19
 
18
20
  [tool.ruff]
@@ -32,4 +34,21 @@ include = ["gaia*"]
32
34
  testpaths = ["tests", "tools/scan/tests"]
33
35
  markers = [
34
36
  "parity: JS-Python CLI parity comparison tests",
37
+ "ci_subset: budget-bounded subset of L2/L3 (LLM) tests run in CI under a controlled token budget (brief #89 AC-6)",
38
+ ]
39
+
40
+ # Branch coverage (brief #89 AC-4): coverage is a DIAGNOSTIC to locate unexercised
41
+ # wiring in the security layer + entry points, NOT a metric-target / vanity gate.
42
+ # No fail_under threshold is set on purpose.
43
+ [tool.coverage.run]
44
+ branch = true
45
+ source = ["hooks/modules/security", "hooks"]
46
+
47
+ [tool.coverage.report]
48
+ show_missing = true
49
+ skip_covered = false
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "cosmic-ray>=8.4.6",
35
54
  ]
@@ -211,9 +211,13 @@ fi
211
211
  # EXPECTED_SCHEMA_VERSION en doctor.py en el mismo commit.
212
212
  # - Para una DB en el FLOOR, esa migración corre directo (la DB está en el
213
213
  # estado source de la migración). No se necesitan variantes _fresh: un
214
- # fresh install ya está en EXPECTED tras schema.sql, así que el loop no
215
- # entra (CURRENT == EXPECTED). El guard-probe por-versión del modelo viejo
216
- # desaparece junto con la cadena histórica.
214
+ # fresh install sella el ledger en el FLOOR (Section 3b) y, cuando hay
215
+ # migraciones forward (EXPECTED > FLOOR), ESTE loop se replaya en cada
216
+ # fresh install desde FLOOR+1 hasta EXPECTED contra una DB cuyos objetos
217
+ # schema.sql ya creó -- por eso las migraciones DEBEN ser idempotentes
218
+ # (CREATE ... IF NOT EXISTS; ADD COLUMN neutralizado por el guard runner).
219
+ # El guard-probe por-versión del modelo viejo desaparece junto con la
220
+ # cadena histórica.
217
221
  #
218
222
  # Cada migración corre en su propia transacción BEGIN/COMMIT. Si falla, abort
219
223
  # -- el ledger NO avanza y el próximo bootstrap retry ve la misma pendiente.
@@ -245,13 +249,58 @@ echo "[bootstrap] schema_version: current=${CURRENT_VERSION}, expected=${EXPECTE
245
249
 
246
250
  MIG_DIR="${SCRIPT_DIR}/migrations"
247
251
 
252
+ # --- Idempotent ADD COLUMN guard (runner-level) ------------------------------
253
+ #
254
+ # Forward migrations are applied on EVERY fresh install: schema.sql produces the
255
+ # EXPECTED shape, the ledger is stamped at the FLOOR, and Section 3c walks
256
+ # FLOOR+1..EXPECTED (this is what test_fresh_install_stamps_floor and
257
+ # test_bootstrap_idempotent_at_floor require). Because schema.sql already
258
+ # carries each migration's target DDL (see migrations/README.md section 1:
259
+ # "add the DDL to schema.sql AND create the migration"), a migration is always
260
+ # replayed against a DB that already has its objects.
261
+ #
262
+ # CREATE ... IF NOT EXISTS makes CREATE statements idempotent under that replay
263
+ # (v18_to_v19 relies on it). But SQLite has NO `ADD COLUMN IF NOT EXISTS`, so a
264
+ # bare `ALTER TABLE t ADD COLUMN c` aborts with "duplicate column name" when the
265
+ # column already exists from schema.sql. This guard restores idempotency for
266
+ # ADD COLUMN at the RUNNER level (not by putting invalid SQL in the .sql file):
267
+ # for each `ALTER TABLE <t> ADD COLUMN <c> ...` line, if column <c> already
268
+ # exists on table <t> (PRAGMA table_info), the line is neutralised (commented
269
+ # out) before the migration runs. Every other statement passes through verbatim.
270
+ #
271
+ # Pure bash + sqlite3, no python3 -- consistent with this script's principles.
272
+ _filter_add_column_idempotent() {
273
+ # $1 = path to the migration .sql file. Emits the (possibly filtered) SQL on
274
+ # stdout. Lines that are `ALTER TABLE t ADD COLUMN c` for an existing column
275
+ # are replaced by a comment; all other lines are passed through unchanged.
276
+ local mig_file="$1"
277
+ local line lower table col exists
278
+ while IFS= read -r line || [ -n "$line" ]; do
279
+ # Normalise whitespace for matching only (emit the ORIGINAL line).
280
+ lower="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')"
281
+ if [[ "$lower" =~ alter[[:space:]]+table[[:space:]]+([a-z0-9_]+)[[:space:]]+add[[:space:]]+column[[:space:]]+([a-z0-9_]+) ]]; then
282
+ table="${BASH_REMATCH[1]}"
283
+ col="${BASH_REMATCH[2]}"
284
+ exists="$(sqlite3 "$GAIA_DB" "SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name='${col}';")"
285
+ if [ "$exists" -gt 0 ]; then
286
+ printf -- '-- [bootstrap] skipped (column %s.%s already present): %s\n' "$table" "$col" "$line"
287
+ continue
288
+ fi
289
+ fi
290
+ printf '%s\n' "$line"
291
+ done < "$mig_file"
292
+ }
293
+
248
294
  if [ "$CURRENT_VERSION" -lt "$EXPECTED_VERSION" ]; then
249
- # Forward-only loop. Reaches here only when a FUTURE migration has been
250
- # added (EXPECTED_SCHEMA_VERSION > FLOOR) and the live DB is behind it.
251
- # On a fresh install the DB is already at EXPECTED (schema.sql produced the
252
- # FLOOR == EXPECTED shape when no forward migrations exist), so this branch
253
- # is skipped entirely. Any DB below the FLOOR was already rejected in
254
- # Section 3b, so CURRENT_VERSION here is always >= FLOOR.
295
+ # Forward-only loop. Runs whenever the live DB is behind EXPECTED, which
296
+ # INCLUDES a fresh install: Section 3b stamps the ledger at the FLOOR, and
297
+ # when forward migrations exist (EXPECTED > FLOOR) a fresh DB sits at the
298
+ # FLOOR while EXPECTED is higher, so it enters here and replays FLOOR+1..
299
+ # EXPECTED. That replay runs against a DB whose objects schema.sql already
300
+ # created, which is exactly why these migrations MUST be idempotent (CREATE
301
+ # ... IF NOT EXISTS; ADD COLUMN neutralised by the runner guard above).
302
+ # Any DB below the FLOOR was already rejected in Section 3b, so
303
+ # CURRENT_VERSION here is always >= FLOOR.
255
304
  for N in $(seq $((CURRENT_VERSION + 1)) "$EXPECTED_VERSION"); do
256
305
  PREV=$((N - 1))
257
306
  MIG_FILE="${MIG_DIR}/v${PREV}_to_v${N}.sql"
@@ -263,15 +312,15 @@ if [ "$CURRENT_VERSION" -lt "$EXPECTED_VERSION" ]; then
263
312
  exit 1
264
313
  fi
265
314
 
266
- # Forward-only: a DB at the FLOOR (or any version below N) is in the
267
- # source state of this migration, so we apply it directly inside an
268
- # explicit transaction. No per-version guard probe and no _fresh
269
- # variant are needed -- the historical "schema.sql already created the
270
- # target table" case only existed because the baseline was v1 and the
271
- # whole chain was walked on every fresh install. Under the FLOOR model
272
- # a fresh install is already at EXPECTED, so it never enters this loop.
315
+ # Apply the migration inside an explicit transaction. The SQL is passed
316
+ # through _filter_add_column_idempotent first so that `ADD COLUMN`
317
+ # statements for columns schema.sql already created are skipped (SQLite
318
+ # lacks `ADD COLUMN IF NOT EXISTS`). CREATE ... IF NOT EXISTS statements
319
+ # are already idempotent and pass through unchanged. This is what lets a
320
+ # fresh install (where schema.sql produced the EXPECTED shape) replay the
321
+ # FLOOR+1..EXPECTED migrations without aborting on duplicate columns.
273
322
  echo "[bootstrap] migration v${PREV}->v${N}: applying ${MIG_FILE}"
274
- MIG_SQL="$(cat "$MIG_FILE")"
323
+ MIG_SQL="$(_filter_add_column_idempotent "$MIG_FILE")"
275
324
  if ! sqlite3 "$GAIA_DB" <<EOF
276
325
  BEGIN;
277
326
  ${MIG_SQL}
@@ -17,25 +17,31 @@ The floor is **v18**. It is declared in three places that must agree:
17
17
 
18
18
  | Location | What it holds |
19
19
  |----------|---------------|
20
- | `gaia/store/schema.sql` | Produces the v18 shape directly (fresh installs land here). |
21
- | `scripts/bootstrap_database.sh` Section 3b (`SCHEMA_FLOOR=18`) | Seeds/stamps the ledger at the floor; rejects DBs below it. |
22
- | `bin/cli/doctor.py` (`EXPECTED_SCHEMA_VERSION`) | The version the CLI expects; equals the floor until a forward migration is added. |
20
+ | `gaia/store/schema.sql` | Produces the **latest** (EXPECTED) shape directly -- fresh installs land here, not at the floor. |
21
+ | `scripts/bootstrap_database.sh` Section 3b (`SCHEMA_FLOOR=18`) | Stamps the fresh ledger at the floor; rejects DBs below it. |
22
+ | `bin/cli/doctor.py` (`EXPECTED_SCHEMA_VERSION`) | The version the CLI expects; equals the floor when no forward migration exists, and the highest migration target once they do. |
23
23
 
24
24
  How bootstrap treats each case:
25
25
 
26
26
  * **Fresh install** (no `schema_version` rows): `schema.sql` already produced
27
- the floor shape, so bootstrap stamps `(version=18, ...)` directly. It does
28
- **not** seed v1 and walk the chain.
29
- * **DB at or above the floor** (the common case, e.g. `~/.gaia/gaia.db`): no
30
- migration needed. Section 3c only runs if a forward migration exists.
27
+ the EXPECTED shape, and Section 3b stamps the ledger at the **floor** (not
28
+ EXPECTED). Section 3c then replays every forward migration from `floor+1` to
29
+ EXPECTED against that already-current DB. It does **not** seed v1 and walk the
30
+ historical chain. Because the migrations run against objects `schema.sql`
31
+ already created, they **must be idempotent** (see section 1).
32
+ * **DB at or above the floor** (the common case, e.g. `~/.gaia/gaia.db`):
33
+ Section 3c applies any forward migrations the DB is still behind on, up to
34
+ EXPECTED.
31
35
  * **DB below the floor** (`1 <= version < 18`): **no longer supported** for
32
36
  in-place upgrade. Bootstrap aborts with a clear message asking you to
33
37
  recreate the DB (back up, delete `~/.gaia/gaia.db`, re-run `gaia install`).
34
38
 
35
39
  There are no `_fresh` / `_merge` variants under the floor model. Those existed
36
40
  only because the old baseline was v1 and the whole chain was walked on every
37
- fresh install. With the floor, a fresh install is already at the expected
38
- version after `schema.sql`, so the migration loop is skipped entirely.
41
+ fresh install. Under the floor model the forward-migration loop is still
42
+ replayed on every fresh install (from `floor+1` to EXPECTED) -- so the single
43
+ forward migration file per bump must be idempotent rather than split into
44
+ `_fresh` / `_merge` variants.
39
45
 
40
46
  ---
41
47
 
@@ -48,14 +54,19 @@ version) to `N`:
48
54
  1. Add the new DDL to `gaia/store/schema.sql` so fresh installs land in the
49
55
  target shape.
50
56
  2. Create exactly one `scripts/migrations/v{N-1}_to_v{N}.sql` containing the
51
- full DDL delta applied to a DB at version `N-1`.
57
+ full DDL delta applied to a DB at version `N-1`. It **must be idempotent**
58
+ (`CREATE ... IF NOT EXISTS`; for `ADD COLUMN`, rely on the runner's
59
+ existence guard -- SQLite has no `ADD COLUMN IF NOT EXISTS`), because it is
60
+ replayed on fresh installs against a DB that already has those objects.
52
61
  3. Bump `EXPECTED_SCHEMA_VERSION` to `N` in `bin/cli/doctor.py` **in the same
53
62
  commit**.
54
63
 
55
64
  `bootstrap_database.sh` Section 3c then applies `v{N-1}_to_v{N}.sql` inside a
56
65
  single `BEGIN/COMMIT` transaction for any DB behind `N`, and stamps the ledger
57
- only on success. A fresh install is already at `N` after `schema.sql`, so it
58
- never enters the loop -- no `_fresh` variant is required.
66
+ only on success. A fresh install stamps the ledger at the floor (Section 3b)
67
+ and then replays `floor+1 .. N` here too -- since `schema.sql` already produced
68
+ the `N` shape, the migration runs against objects that already exist, which is
69
+ exactly why it must be idempotent (no `_fresh` variant is used).
59
70
 
60
71
  `tests/cli/test_schema_version_lockstep.py` enforces that
61
72
  `EXPECTED_SCHEMA_VERSION` equals the floor when no forward migrations exist,
@@ -93,8 +104,9 @@ as the ledger grows.
93
104
  | `vN_to_vN+1.sql` | Applied to an existing DB at version N. Contains the full DDL delta, applied inside a `BEGIN/COMMIT` transaction by bootstrap Section 3c. |
94
105
 
95
106
  The historical `_fresh` and `_merge` variants are no longer used: under the
96
- floor model a fresh install is already at the expected version after
97
- `schema.sql`, so it never runs a migration script.
107
+ floor model a single idempotent `vN_to_vN+1.sql` covers both an in-place
108
+ upgrade and the fresh-install replay (Section 3c walks `floor+1 .. EXPECTED`
109
+ on every fresh install), so one idempotent file replaces the old split.
98
110
 
99
111
  ---
100
112
 
@@ -4,5 +4,5 @@
4
4
  # corresponds to (EXPECTED_SCHEMA_VERSION in bin/cli/doctor.py).
5
5
  # Do NOT edit by hand: bump EXPECTED_SCHEMA_VERSION + add a migration,
6
6
  # then re-run the guard to refresh this file.
7
- version=18
8
- sha256=6f728de0625d5011b86eaf536c21785d19cfa08592ecaf086ab46f9b0d0ebda0
7
+ version=20
8
+ sha256=027c8a61e8217b40cbdb07d0b83e7fb18f1ec671d3069dc0a0ce6bb4cc0e8ee9
@@ -0,0 +1,36 @@
1
+ -- Migration v18 -> v19: audit-immutability gap closure (Task B).
2
+ --
3
+ -- Adds BEFORE UPDATE trigger bu_approvals_status_has_event on the approvals
4
+ -- table to enforce that every approvals.status transition is accompanied by a
5
+ -- preceding event row in the append-only approval_events chain.
6
+ --
7
+ -- The trigger fires when status changes to 'approved', 'rejected', or 'revoked'.
8
+ -- It checks that an event row with the matching event_type exists for the
9
+ -- approval_id within the same transaction. If no matching event is found it
10
+ -- raises ABORT, rolling back the UPDATE.
11
+ --
12
+ -- This closes the gap where a direct UPDATE approvals SET status = 'approved'
13
+ -- could flip the status column without leaving an auditable event, violating
14
+ -- the "auditable + immutable" invariant of the approval_events chain.
15
+ --
16
+ -- Bootstrap note: a fresh install (schema.sql) already includes this trigger
17
+ -- via CREATE TRIGGER IF NOT EXISTS; this migration only adds it to existing DBs
18
+ -- that were initialized before v19.
19
+
20
+ CREATE TRIGGER IF NOT EXISTS bu_approvals_status_has_event
21
+ BEFORE UPDATE OF status ON approvals
22
+ WHEN NEW.status != OLD.status AND NEW.status IN ('approved', 'rejected', 'revoked')
23
+ BEGIN
24
+ SELECT CASE
25
+ WHEN (
26
+ SELECT COUNT(*) FROM approval_events
27
+ WHERE approval_id = NEW.id
28
+ AND event_type = CASE NEW.status
29
+ WHEN 'approved' THEN 'APPROVED'
30
+ WHEN 'rejected' THEN 'REJECTED'
31
+ WHEN 'revoked' THEN 'REVOKED'
32
+ END
33
+ ) = 0
34
+ THEN RAISE(ABORT, 'approvals: status change requires a preceding event in approval_events')
35
+ END;
36
+ END;
@@ -0,0 +1,20 @@
1
+ -- Migration v19 -> v20: add multi_use and confirmed columns to approval_grants.
2
+ --
3
+ -- These two columns support the upcoming FS-grant-plane migration:
4
+ -- multi_use INTEGER NOT NULL DEFAULT 0 -- 1 = multi-use grant, 0 = single-use (BOOLEAN)
5
+ -- confirmed INTEGER NOT NULL DEFAULT 0 -- 1 = grant confirmed by user, 0 = pending (BOOLEAN)
6
+ --
7
+ -- Both columns use the established boolean-as-INTEGER convention (DEFAULT 0)
8
+ -- already in use across this schema (e.g. allow_write, can_read, can_write).
9
+ --
10
+ -- SQLite ALTER TABLE ADD COLUMN is safe and additive: existing rows receive the
11
+ -- DEFAULT value and the table is NOT rebuilt. Zero data loss is guaranteed by
12
+ -- the SQLite specification (https://www.sqlite.org/lang_altertable.html).
13
+ --
14
+ -- Bootstrap note: migrations run once via the version chain. A fresh install
15
+ -- seeds from schema.sql (which already includes both columns) and then skips
16
+ -- this migration file by version-gating; this file runs only against existing
17
+ -- DBs initialized before v20 (which do not yet have these columns).
18
+
19
+ ALTER TABLE approval_grants ADD COLUMN multi_use INTEGER NOT NULL DEFAULT 0;
20
+ ALTER TABLE approval_grants ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0;