nocturne-memory 0.1.12__tar.gz

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 (107) hide show
  1. nocturne_memory-0.1.12/.gitignore +9 -0
  2. nocturne_memory-0.1.12/Dockerfile +28 -0
  3. nocturne_memory-0.1.12/PKG-INFO +46 -0
  4. nocturne_memory-0.1.12/README.md +22 -0
  5. nocturne_memory-0.1.12/api_contract_fingerprints.json +14 -0
  6. nocturne_memory-0.1.12/infra/billing-breaker/README.md +545 -0
  7. nocturne_memory-0.1.12/infra/billing-breaker/billing_breaker.py +221 -0
  8. nocturne_memory-0.1.12/infra/billing-breaker/deploy.sh +692 -0
  9. nocturne_memory-0.1.12/infra/billing-breaker/deployment_checks.py +934 -0
  10. nocturne_memory-0.1.12/infra/billing-breaker/main.py +51 -0
  11. nocturne_memory-0.1.12/infra/billing-breaker/requirements.txt +2 -0
  12. nocturne_memory-0.1.12/pyproject.toml +78 -0
  13. nocturne_memory-0.1.12/src/spine/__init__.py +3 -0
  14. nocturne_memory-0.1.12/src/spine/api_contract.py +47 -0
  15. nocturne_memory-0.1.12/src/spine/auth.py +38 -0
  16. nocturne_memory-0.1.12/src/spine/config.py +69 -0
  17. nocturne_memory-0.1.12/src/spine/contracts.py +210 -0
  18. nocturne_memory-0.1.12/src/spine/curation/__init__.py +2 -0
  19. nocturne_memory-0.1.12/src/spine/curation/contracts.py +117 -0
  20. nocturne_memory-0.1.12/src/spine/curation/diagnostics.py +307 -0
  21. nocturne_memory-0.1.12/src/spine/curation/provider.py +236 -0
  22. nocturne_memory-0.1.12/src/spine/curation/router.py +48 -0
  23. nocturne_memory-0.1.12/src/spine/curation/service.py +504 -0
  24. nocturne_memory-0.1.12/src/spine/curation/worker.py +49 -0
  25. nocturne_memory-0.1.12/src/spine/db/__init__.py +1 -0
  26. nocturne_memory-0.1.12/src/spine/db/engine.py +9 -0
  27. nocturne_memory-0.1.12/src/spine/db/locking.py +81 -0
  28. nocturne_memory-0.1.12/src/spine/db/memory.py +297 -0
  29. nocturne_memory-0.1.12/src/spine/db/migrate.py +73 -0
  30. nocturne_memory-0.1.12/src/spine/db/migrations/README +1 -0
  31. nocturne_memory-0.1.12/src/spine/db/migrations/env.py +71 -0
  32. nocturne_memory-0.1.12/src/spine/db/migrations/script.py.mako +25 -0
  33. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0001_c2_schema.py +152 -0
  34. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0002_origin_path.py +27 -0
  35. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0003_spend_ledger.py +283 -0
  36. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0004_hybrid_candidate_fts.py +67 -0
  37. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0005_per_message_rescore.py +33 -0
  38. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0006_extraction_queue.py +76 -0
  39. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0007_seed_ingestion.py +64 -0
  40. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0008_scorer_console.py +43 -0
  41. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0009_spend_reconciliation.py +93 -0
  42. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0010_budget_cut_band.py +34 -0
  43. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0011_learner_runs.py +64 -0
  44. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0012_injection_event_annotations.py +75 -0
  45. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0013_transcript_records.py +61 -0
  46. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0014_location_scorer.py +121 -0
  47. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0015_symphony_memory_bridge.py +159 -0
  48. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0016_thread_index.py +124 -0
  49. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0017_memory_share.py +117 -0
  50. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0018_curator_runs.py +270 -0
  51. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0019_curator_pressure.py +118 -0
  52. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0020_optimization_runs.py +95 -0
  53. nocturne_memory-0.1.12/src/spine/db/migrations/versions/0021_origin_location.py +109 -0
  54. nocturne_memory-0.1.12/src/spine/db/models.py +1063 -0
  55. nocturne_memory-0.1.12/src/spine/db/session.py +9 -0
  56. nocturne_memory-0.1.12/src/spine/deploy_resources.py +106 -0
  57. nocturne_memory-0.1.12/src/spine/embeddings/__init__.py +446 -0
  58. nocturne_memory-0.1.12/src/spine/embeddings/router.py +88 -0
  59. nocturne_memory-0.1.12/src/spine/events/__init__.py +1 -0
  60. nocturne_memory-0.1.12/src/spine/ids.py +33 -0
  61. nocturne_memory-0.1.12/src/spine/inject/__init__.py +1 -0
  62. nocturne_memory-0.1.12/src/spine/inject/annotations.py +143 -0
  63. nocturne_memory-0.1.12/src/spine/inject/decisions.py +563 -0
  64. nocturne_memory-0.1.12/src/spine/inject/renderer.py +84 -0
  65. nocturne_memory-0.1.12/src/spine/inject/router.py +308 -0
  66. nocturne_memory-0.1.12/src/spine/inject/scorer.py +741 -0
  67. nocturne_memory-0.1.12/src/spine/inject/service.py +774 -0
  68. nocturne_memory-0.1.12/src/spine/learner/__init__.py +1 -0
  69. nocturne_memory-0.1.12/src/spine/learner/contracts.py +29 -0
  70. nocturne_memory-0.1.12/src/spine/learner/evidence.py +321 -0
  71. nocturne_memory-0.1.12/src/spine/learner/locking.py +5 -0
  72. nocturne_memory-0.1.12/src/spine/learner/model.py +846 -0
  73. nocturne_memory-0.1.12/src/spine/learner/router.py +28 -0
  74. nocturne_memory-0.1.12/src/spine/learner/service.py +822 -0
  75. nocturne_memory-0.1.12/src/spine/learner/worker.py +63 -0
  76. nocturne_memory-0.1.12/src/spine/m2k/__init__.py +1 -0
  77. nocturne_memory-0.1.12/src/spine/m2k/contracts.py +394 -0
  78. nocturne_memory-0.1.12/src/spine/m2k/router.py +141 -0
  79. nocturne_memory-0.1.12/src/spine/m2k/service.py +1580 -0
  80. nocturne_memory-0.1.12/src/spine/main.py +325 -0
  81. nocturne_memory-0.1.12/src/spine/memory/__init__.py +1 -0
  82. nocturne_memory-0.1.12/src/spine/memory/router.py +393 -0
  83. nocturne_memory-0.1.12/src/spine/memory/service.py +1386 -0
  84. nocturne_memory-0.1.12/src/spine/problems.py +69 -0
  85. nocturne_memory-0.1.12/src/spine/queue/__init__.py +1 -0
  86. nocturne_memory-0.1.12/src/spine/queue/contracts.py +125 -0
  87. nocturne_memory-0.1.12/src/spine/queue/router.py +106 -0
  88. nocturne_memory-0.1.12/src/spine/queue/service.py +972 -0
  89. nocturne_memory-0.1.12/src/spine/spend/__init__.py +6 -0
  90. nocturne_memory-0.1.12/src/spine/spend/contracts.py +214 -0
  91. nocturne_memory-0.1.12/src/spine/spend/reconciliation.py +255 -0
  92. nocturne_memory-0.1.12/src/spine/spend/router.py +70 -0
  93. nocturne_memory-0.1.12/src/spine/spend/service.py +258 -0
  94. nocturne_memory-0.1.12/src/spine/spend/views.py +72 -0
  95. nocturne_memory-0.1.12/src/spine/symphony/__init__.py +1 -0
  96. nocturne_memory-0.1.12/src/spine/symphony/contracts.py +129 -0
  97. nocturne_memory-0.1.12/src/spine/symphony/router.py +75 -0
  98. nocturne_memory-0.1.12/src/spine/symphony/service.py +248 -0
  99. nocturne_memory-0.1.12/src/spine/tokens.py +19 -0
  100. nocturne_memory-0.1.12/src/spine/transcripts/__init__.py +1 -0
  101. nocturne_memory-0.1.12/src/spine/transcripts/contracts.py +107 -0
  102. nocturne_memory-0.1.12/src/spine/transcripts/router.py +55 -0
  103. nocturne_memory-0.1.12/src/spine/transcripts/service.py +118 -0
  104. nocturne_memory-0.1.12/src/spine/vitals/__init__.py +6 -0
  105. nocturne_memory-0.1.12/src/spine/vitals/contracts.py +241 -0
  106. nocturne_memory-0.1.12/src/spine/vitals/router.py +63 -0
  107. nocturne_memory-0.1.12/src/spine/vitals/service.py +396 -0
@@ -0,0 +1,9 @@
1
+ .env
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ dist/
9
+ /verification/*/impacted.json
@@ -0,0 +1,28 @@
1
+ FROM python:3.12.13-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS base
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1
5
+
6
+ FROM base AS builder
7
+
8
+ WORKDIR /build
9
+
10
+ COPY pyproject.toml ./
11
+ COPY README.md ./
12
+ COPY Dockerfile ./
13
+ COPY src ./src
14
+ COPY infra ./infra
15
+
16
+ RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels .
17
+
18
+ FROM base AS runtime
19
+
20
+ WORKDIR /app
21
+
22
+ COPY --from=builder /wheels /wheels
23
+
24
+ RUN pip install --no-cache-dir /wheels/nocturne_memory-*.whl && rm -rf /wheels
25
+
26
+ EXPOSE 8000
27
+
28
+ CMD ["uvicorn", "spine.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: nocturne-memory
3
+ Version: 0.1.12
4
+ Summary: NOCTURNE Memory Palace service
5
+ Project-URL: Homepage, https://github.com/Nate0-1999/nocturne
6
+ Project-URL: Source, https://github.com/Nate0-1999/nocturne-memory
7
+ Requires-Python: <3.13,>=3.12
8
+ Requires-Dist: alembic<2,>=1.13
9
+ Requires-Dist: asyncpg<1,>=0.29
10
+ Requires-Dist: fastapi<1,>=0.115
11
+ Requires-Dist: httpx<1,>=0.27
12
+ Requires-Dist: pgvector<1,>=0.3
13
+ Requires-Dist: pydantic-settings<3,>=2
14
+ Requires-Dist: pydantic<3,>=2
15
+ Requires-Dist: sqlalchemy[asyncio]<3,>=2
16
+ Requires-Dist: tiktoken<1,>=0.9
17
+ Requires-Dist: uvicorn[standard]<1,>=0.30
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
20
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
21
+ Requires-Dist: ruff<1,>=0.9; extra == 'dev'
22
+ Requires-Dist: testcontainers[postgres]<5,>=4.8; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # nocturne-memory
26
+
27
+ `nocturne-memory` is NOCTURNE's Memory Palace service and migration package. It
28
+ is released in lockstep as a dependency of the public `nocturne-harness`
29
+ distribution.
30
+
31
+ The service owns the extraction consent boundary at `/v1/extractions` and
32
+ `/v1/approval-queue`. Pending units use the internal `candidate` lifecycle
33
+ state and are excluded from ordinary memory listing, search, and injection.
34
+ Queue decisions are append-only; approval enacts the proposed verdict through
35
+ revisioned head changes and typed lineage edges, while denial revision-
36
+ tombstones the candidate as rejected.
37
+
38
+ Injection treats memory room as a versioned share of the active model context
39
+ window. The share is a ceiling, not a quota: unused room remains available to
40
+ conversation, while pinned memories always inject and report any overflow in
41
+ the prepare allocation. Share and threshold join the ordinary replay learner
42
+ after 100 authentic, hygiene-filtered owner dispositions; every learned
43
+ generation remains proposed until the owner activates it.
44
+
45
+ New users should start at the [NOCTURNE quickstart](https://github.com/Nate0-1999/nocturne)
46
+ and install `nocturne-harness`; they do not need to install this package separately.
@@ -0,0 +1,22 @@
1
+ # nocturne-memory
2
+
3
+ `nocturne-memory` is NOCTURNE's Memory Palace service and migration package. It
4
+ is released in lockstep as a dependency of the public `nocturne-harness`
5
+ distribution.
6
+
7
+ The service owns the extraction consent boundary at `/v1/extractions` and
8
+ `/v1/approval-queue`. Pending units use the internal `candidate` lifecycle
9
+ state and are excluded from ordinary memory listing, search, and injection.
10
+ Queue decisions are append-only; approval enacts the proposed verdict through
11
+ revisioned head changes and typed lineage edges, while denial revision-
12
+ tombstones the candidate as rejected.
13
+
14
+ Injection treats memory room as a versioned share of the active model context
15
+ window. The share is a ceiling, not a quota: unused room remains available to
16
+ conversation, while pinned memories always inject and report any overflow in
17
+ the prepare allocation. Share and threshold join the ordinary replay learner
18
+ after 100 authentic, hygiene-filtered owner dispositions; every learned
19
+ generation remains proposed until the owner activates it.
20
+
21
+ New users should start at the [NOCTURNE quickstart](https://github.com/Nate0-1999/nocturne)
22
+ and install `nocturne-harness`; they do not need to install this package separately.
@@ -0,0 +1,14 @@
1
+ {
2
+ "0.1.0": "ebc263f97e4b357e3370d2b5a188f752bf67b1743d6cde9bd68acff853703e33",
3
+ "0.1.1": "ebc263f97e4b357e3370d2b5a188f752bf67b1743d6cde9bd68acff853703e33",
4
+ "0.1.10": "503eaed7b386b0a96ab56a444081f1a37a28f113cbc6e6617e434af82765794d",
5
+ "0.1.11": "304172efb12e804494edb7cccf11f2d1d00ef69d0e204fa659595dddf10dc20c",
6
+ "0.1.2": "ec1b87185670ac86377b389ac67c536fb295dac029e4e97c4ffcccd3017cee5b",
7
+ "0.1.3": "4fc4d8920ce54ea0766c933719429bf14e7372c1a5ab6f80a5b2d5d42adf699b",
8
+ "0.1.4": "cb7499803498e8097529f3812b9bfeb094f100ff6761b29e461c56b1674ae38d",
9
+ "0.1.5": "3867455375aabcf861c87af0432d0c27f710cdb13aac199e86f2227a9a16777b",
10
+ "0.1.6": "48d16b6a328e61dd31a9f84691b4a01fc4107b13adc99955de18dec22278dce2",
11
+ "0.1.7": "b8ff406c1f57d0bfc2a9a175940da48dd4d258b11d4b0695ecba44e8aec910b3",
12
+ "0.1.8": "d182caa605cd1430c7fba0012ade031cb78ab5308043d25e18aaf552500e182a",
13
+ "0.1.9": "662ac885d8a5f39f10464d2f5db545aed435fa039220cf5d65c22228e653ba94"
14
+ }
@@ -0,0 +1,545 @@
1
+ # D2 billing circuit breaker
2
+
3
+ > STATUS: DEPLOYED AND ARMED in `n8-memory-palace` on 2026-07-21. The
4
+ > `billing-breaker` topic, private no-retry function, isolated identities,
5
+ > detach-role binding, and budget wiring are live and least-privilege
6
+ > verified. To change or remove it, use the cleanup procedure below, then
7
+ > re-run `deploy.sh --apply` (it is first-deploy-only and refuses to run
8
+ > while these resources exist). The preflight was hardened at deploy time to
9
+ > run against a default-posture GCP project (recognizes Google default
10
+ > identities: Compute, App Engine `@appspot`, Container Registry, and
11
+ > `gcp-sa-*` agents); see DECISIONS 017–018.
12
+
13
+ This package is an armed, one-way cost control for `n8-memory-palace`. A valid
14
+ budget message with `costAmount >= budgetAmount` calls Cloud Billing
15
+ `updateBillingInfo` with an empty `billingAccountName`. That deliberately
16
+ detaches the project's billing account and can stop or irretrievably delete
17
+ Cloud Run, Cloud SQL, and every other billable project service. There is no
18
+ simulation switch in the deployed function.
19
+
20
+ Agents may build and test this directory. **Only a human may run `deploy.sh
21
+ --apply`, publish a live message, alter IAM, or execute recovery commands.**
22
+
23
+ ## What this does
24
+
25
+ - accepts only Pub/Sub schema `1.0` messages whose `billingAccountId` and
26
+ `budgetId` match deploy-time settings;
27
+ - keeps the destructive target fixed in source as `projects/n8-memory-palace`;
28
+ - compares JSON numbers as decimals and detaches at equality or overage;
29
+ - emits one-line structured JSON decisions before and after the action;
30
+ - acknowledges malformed, foreign, and below-budget messages without creating
31
+ a Cloud Billing client;
32
+ - surfaces Cloud Billing failures, while the deployed trigger explicitly does
33
+ not retry failed events; the next periodic budget status is the next attempt;
34
+ - makes duplicate delivery state-idempotent by repeatedly requesting the same
35
+ empty billing-account assignment, without a broader status-read permission.
36
+
37
+ Google documents the [budget notification schema, periodic delivery, and
38
+ at-least-once semantics](https://docs.cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications),
39
+ the [empty-account detach operation](https://docs.cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo),
40
+ the [disable-billing pattern and blast radius](https://docs.cloud.google.com/billing/docs/how-to/disable-billing-with-notifications),
41
+ and the [function retry hazard](https://docs.cloud.google.com/run/docs/tips/function-retries).
42
+ Retries are disabled because a permanent IAM/configuration failure could
43
+ otherwise keep an old over-budget event alive and detach again after recovery.
44
+
45
+ This is not a hard or real-time $100 cap. Budget data is estimated and delayed,
46
+ notifications arrive only several times per day, delivery can be duplicated or
47
+ out of order, and already-incurred but unreported charges remain payable.
48
+
49
+ ## Human preflight
50
+
51
+ Stop if any item is uncertain:
52
+
53
+ 1. Put the Cloud SQL backup and D1 redeploy material **outside
54
+ `n8-memory-palace`**. A backup in the project can become unavailable with the
55
+ project it is meant to restore. Google warns that disabling billing can
56
+ remove resources and recovery is not guaranteed.
57
+ 2. In Cloud Billing Reports, verify the current-period **actual** cost is below
58
+ USD 100 immediately before arming. If it is already at or over USD 100,
59
+ connecting the budget is an intentional near-term outage.
60
+ 3. Confirm the existing budget has `ownershipScope: BILLING_ACCOUNT`, is
61
+ recurring monthly, specified USD 100.00, filtered to only the numeric project
62
+ resource for `n8-memory-palace`, and has no service, label, subaccount,
63
+ resource-ancestor, custom-period, or credit filter. Its Pub/Sub notification
64
+ topic must be disconnected.
65
+ 4. Confirm the project-to-billing-account link is unlocked and the account has
66
+ no active or pending commitment that prevents detachment.
67
+ 5. Audit inherited/custom access on both the project and the named billing
68
+ account for `billing.budgets.update`, `billing.resourcebudgets.write`,
69
+ `billing.accounts.setIamPolicy`, Pub/Sub topic/subscription update or IAM,
70
+ Eventarc trigger update or IAM, topic subscription attachment/detachment,
71
+ `pubsub.topics.publish`, `run.routes.invoke`, billing association changes,
72
+ `iam.serviceAccounts.actAs`, token/key minting, and IAM-policy mutation.
73
+ Pub/Sub attributes are not signatures, and a principal that can retarget or
74
+ lower the exact budget can make Google's publisher emit an authentic outage
75
+ event. The budget must use `ownershipScope: BILLING_ACCOUNT`, making project
76
+ principals read-only even when they otherwise have resource-budget write
77
+ permission. For every direct project and billing-account binding, the script
78
+ reads
79
+ the role's current `includedPermissions`; only the active human may directly
80
+ control the budget or billing-account IAM (matched case-insensitively on the
81
+ email, since Google account emails are case-insensitive). It rejects other
82
+ dangerous project permissions except the exact armed runtime binding and
83
+ Google-managed, project-number-pinned service agents — the default Cloud
84
+ Build, cloudservices, and Compute Engine accounts, a curated set of
85
+ Google-owned service-agent domains, and Google's reserved `gcp-sa-*` per-
86
+ service agents. These identities are created and controlled by Google for
87
+ this project and are not freely mintable, so a default-posture project is
88
+ deployable; a service account minted in any other project (its own
89
+ `<project-id>` SA domain) stays audited. It separately requires
90
+ empty direct policies and no user-managed keys on all fresh D2 identities,
91
+ only Google's budget publisher on the topic, and only the trigger identity
92
+ on the service. The armed path must have one target-project Eventarc
93
+ subscription that exactly equals the function-owned Eventarc trigger's
94
+ output-only transport, a healthy exact destination, no direct subscription
95
+ policy, and no topic or subscription Single Message Transforms because those
96
+ can rewrite both payload data and attributes. Inherited roles, group
97
+ membership, and humans who can command
98
+ a trusted service agent are not fully resolved by those direct policies and
99
+ remain a manual destructive-admin boundary.
100
+ 6. Use a human identity, not service-account credentials. Remove all effective
101
+ gcloud impersonation, access-token-file, and credential-file overrides; the
102
+ script refuses them before trusting `gcloud auth list`. The simplest
103
+ auditable gate is the sole direct Project Owner on `n8-memory-palace` plus
104
+ Billing Account Administrator on the named account. Any other direct
105
+ principal with a dangerous current permission makes the script stop. The
106
+ named `roles/billing.projectManager` binding must be absent before arming;
107
+ D2 reserves that exact role for its runtime identity. Recovery still needs
108
+ the permissions documented by Google: Project Billing Manager + Browser +
109
+ Service Usage Viewer on the project and Billing Account User + Viewer on the
110
+ account, obtained only after the breaker is torn down if Owner is not used.
111
+
112
+ The [billing-link permissions](https://docs.cloud.google.com/billing/docs/how-to/modify-project#required_permissions)
113
+ matter during recovery: Billing Account Costs Manager alone cannot reattach a
114
+ project.
115
+
116
+ Identify the exact existing budget; never select it by display name:
117
+
118
+ ```sh
119
+ export BILLING_ACCOUNT_ID=000000-000000-000000
120
+ gcloud billing budgets list --billing-account="${BILLING_ACCOUNT_ID}"
121
+ export BUDGET_RESOURCE="billingAccounts/${BILLING_ACCOUNT_ID}/budgets/BUDGET_ID"
122
+ gcloud billing budgets describe "${BUDGET_RESOURCE}"
123
+ ```
124
+
125
+ ## Review, then deploy
126
+
127
+ The dry run is local-only: it validates its two arguments and prints the plan
128
+ without looking for or invoking `gcloud`.
129
+
130
+ ```sh
131
+ cd infra/billing-breaker
132
+ export BILLING_ACCOUNT_ID=000000-000000-000000
133
+ export BUDGET_RESOURCE="billingAccounts/${BILLING_ACCOUNT_ID}/budgets/BUDGET_ID"
134
+ ./deploy.sh --dry-run
135
+ ```
136
+
137
+ `--apply` is deliberately first-deploy-only. The function, Cloud Run service,
138
+ topic, every Eventarc trigger that references a D2 name, and all three service
139
+ accounts must be absent. A failed deployment or a rearm must go through the
140
+ cleanup procedure instead of silently adopting unknown identities, keys,
141
+ triggers, or queued messages.
142
+
143
+ After reviewing the plan and completing the preflight, a human can enter the
144
+ interactive gate:
145
+
146
+ ```sh
147
+ export CONFIRM_D2_PROJECT=n8-memory-palace
148
+ ./deploy.sh --apply
149
+ ```
150
+
151
+ The script repeats the project, budget, account, destructive action, and
152
+ below-USD-100 assertion in a typed confirmation before its first mutation. It
153
+ rejects ambient credential overrides, resolves every direct role to its current
154
+ permissions, and uses successful list responses for its fresh-resource checks,
155
+ rather than treating any failed `describe` as absence. It then:
156
+
157
+ 1. validates the enabled billing link and exact disconnected budget;
158
+ 2. creates a fresh `billing-breaker` topic;
159
+ 3. separates the runtime, Eventarc trigger, and build identities;
160
+ 4. gives the build identity only Google's documented build roles and removes
161
+ all three immediately when the build completes;
162
+ 5. deploys a private Python 3.12 second-generation function with retries off;
163
+ 6. gives the trigger identity Invoker only on this Cloud Run service and gives
164
+ only Google's budget publisher direct topic Publisher access;
165
+ 7. validates the complete inert topology;
166
+ 8. requires the named Project Billing Manager role to be absent, then gives its
167
+ sole unconditional membership to the runtime identity on this project
168
+ immediately before connecting the budget last; and
169
+ 9. asserts the complete armed state. Any later error enters a rollback that
170
+ revokes applicable runtime/build roles and reads the project policy back.
171
+ If absence cannot be proved, the script exits with a `CRITICAL` warning and
172
+ the operator must assume detach authority remains active.
173
+
174
+ The runtime receives no billing-account, Artifact Registry, build, Eventarc,
175
+ or project-wide Invoker role. The trigger cannot detach billing, and the build
176
+ identity has no standing project role after deployment. Google documents the
177
+ [custom build identity roles](https://docs.cloud.google.com/functions/docs/building#secure_your_build_with_a_custom_service_account).
178
+
179
+ ## Synthetic-message drill
180
+
181
+ The live drill is intentionally below budget. Equality and overage are proven
182
+ with fake-client unit tests; publishing either value to the armed topic would
183
+ perform the outage this breaker exists to cause. Do not add a new Publisher
184
+ binding merely to run the drill; use an already-trusted Project Owner or skip
185
+ the live drill.
186
+
187
+ ```sh
188
+ export BUDGET_ID="${BUDGET_RESOURCE##*/}"
189
+ if ! drill_message_id="$(
190
+ gcloud pubsub topics publish billing-breaker \
191
+ --project=n8-memory-palace \
192
+ --attribute="billingAccountId=${BILLING_ACCOUNT_ID},budgetId=${BUDGET_ID},schemaVersion=1.0" \
193
+ --message='{"budgetDisplayName":"D2 safe drill","costAmount":99.99,"costIntervalStart":"2026-07-01T00:00:00Z","budgetAmount":100.00,"budgetAmountType":"SPECIFIED_AMOUNT","currencyCode":"USD"}' \
194
+ --format='value(messageIds)'
195
+ )"; then
196
+ echo "publish failed; drill did not pass" >&2
197
+ exit 1
198
+ fi
199
+ if [[ -z "${drill_message_id}" || "${drill_message_id}" == *$'\n'* ]]; then
200
+ echo "publish did not return exactly one message ID" >&2
201
+ exit 1
202
+ fi
203
+ log_filter="resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"billing-breaker\" AND jsonPayload.component=\"billing-breaker\" AND jsonPayload.action=\"below_budget\" AND jsonPayload.message_id=\"${drill_message_id}\""
204
+ if ! drill_log="$(
205
+ gcloud logging read "${log_filter}" \
206
+ --project=n8-memory-palace --limit=1 --format=json
207
+ )"; then
208
+ echo "log lookup failed; drill did not pass" >&2
209
+ exit 1
210
+ fi
211
+ DRILL_LOG="${drill_log}" python3 - <<'PY'
212
+ import json
213
+ import os
214
+
215
+ if not json.loads(os.environ["DRILL_LOG"]):
216
+ raise SystemExit("no below_budget decision matched the published message ID")
217
+ PY
218
+ if ! billing_json="$(
219
+ gcloud billing projects describe n8-memory-palace --format=json
220
+ )"; then
221
+ echo "billing lookup failed; drill did not pass" >&2
222
+ exit 1
223
+ fi
224
+ if ! printf '%s' "${billing_json}" | python3 deployment_checks.py billing \
225
+ --billing-account-id "${BILLING_ACCOUNT_ID}"; then
226
+ echo "billing validator failed; drill did not pass" >&2
227
+ exit 1
228
+ fi
229
+ ```
230
+
231
+ Pass means the exact published message ID has a `below_budget` JSON decision
232
+ and the machine-checked billing link remains enabled on the expected account.
233
+ Eventarc can take up to two minutes to propagate; an empty log result is a
234
+ failed/incomplete drill, so wait and repeat only the log and billing checks.
235
+ Do not republish merely because the log is delayed. Do not publish a message at
236
+ or above 100 to the live topic as a routine test. At-least-once delivery can
237
+ duplicate even a successful publish.
238
+
239
+ ## Recovery and billing reattach
240
+
241
+ The order is a safety invariant. Do not restore the runtime role while an old
242
+ trigger, subscription, or topic exists, and do not reattach billing until the
243
+ breaker has neither authority nor a queue.
244
+
245
+ Set the exact resources first and run every block in the same Bash session.
246
+ Every absence proof below comes from a successful list request plus the same
247
+ exact-value validator used by deployment. Every validator is explicitly
248
+ guarded; permission, network, and parsing errors stop recovery.
249
+
250
+ ```sh
251
+ : "${BUDGET_RESOURCE:?set the exact existing budget resource}"
252
+ : "${BILLING_ACCOUNT_ID:?set the billing account ID}"
253
+ ```
254
+
255
+ 1. In Cloud Billing **Budgets & alerts**, edit `BUDGET_RESOURCE`, disconnect
256
+ its Pub/Sub topic, save, and machine-check that the field is empty:
257
+
258
+ ```sh
259
+ if ! budget_topic="$(
260
+ gcloud billing budgets describe "${BUDGET_RESOURCE}" \
261
+ --format='value(notificationsRule.pubsubTopic)'
262
+ )"; then
263
+ echo "budget lookup failed; disconnection is not proved" >&2
264
+ exit 1
265
+ fi
266
+ if [[ -n "${budget_topic}" ]]; then
267
+ printf 'budget is still connected to %s\n' "${budget_topic}" >&2
268
+ exit 1
269
+ fi
270
+ ```
271
+
272
+ 2. Revoke detach authority and verify the named role is completely absent:
273
+
274
+ ```sh
275
+ gcloud projects remove-iam-policy-binding n8-memory-palace \
276
+ --member="serviceAccount:billing-breaker-runtime@n8-memory-palace.iam.gserviceaccount.com" \
277
+ --role=roles/billing.projectManager || true
278
+ if ! project_policy="$(
279
+ gcloud projects get-iam-policy n8-memory-palace --format=json
280
+ )"; then
281
+ echo "project policy lookup failed; detach-role removal is not proved" >&2
282
+ exit 1
283
+ fi
284
+ if ! printf '%s' "${project_policy}" | \
285
+ python3 deployment_checks.py exact-project-role \
286
+ --role=roles/billing.projectManager --state=absent; then
287
+ echo "detach-role validator failed; removal is not proved" >&2
288
+ exit 1
289
+ fi
290
+ ```
291
+
292
+ The removal command is allowed to report an already-absent binding, but the
293
+ policy read and exact empty-role validator must both succeed.
294
+
295
+ 3. Capture every subscription attached to the dedicated topic from the
296
+ topic-side Pub/Sub list, including subscriptions owned by another project,
297
+ and independently capture every matching Eventarc trigger. Then delete the
298
+ function, triggers, Cloud Run service, and all captured subscriptions. This
299
+ sweep includes an orphan left by a partial Eventarc teardown. If the human
300
+ cannot delete a cross-project subscription, detach it using topic authority;
301
+ detached messages are deleted and the subscription cannot be reattached.
302
+ The current `gcloud functions delete` command does not take `--gen2`.
303
+ Deleting a Pub/Sub topic alone does not delete its subscriptions or retained
304
+ backlog, so topic-side absence is proved before topic deletion. Google
305
+ documents [topic deletion](https://docs.cloud.google.com/pubsub/docs/delete-topic)
306
+ and [subscription detachment](https://docs.cloud.google.com/pubsub/docs/detach-subscriptions).
307
+
308
+ ```sh
309
+ if ! subscription_before_json="$(
310
+ gcloud pubsub topics list-subscriptions billing-breaker \
311
+ --project=n8-memory-palace --format=json
312
+ )"; then
313
+ echo "subscription lookup failed; topic-wide queue capture is not proved" >&2
314
+ exit 1
315
+ fi
316
+ if ! subscription_rows="$(
317
+ printf '%s' "${subscription_before_json}" | \
318
+ python3 deployment_checks.py topic-subscriptions --state=list
319
+ )"; then
320
+ echo "topic-wide queue parsing failed; queue identity is not proved" >&2
321
+ exit 1
322
+ fi
323
+ if ! eventarc_before_json="$(
324
+ gcloud eventarc triggers list \
325
+ --location=us-central1 --project=n8-memory-palace --format=json
326
+ )"; then
327
+ echo "Eventarc lookup failed; transport capture is not proved" >&2
328
+ exit 1
329
+ fi
330
+ if ! trigger_rows="$(EVENTARC_JSON="${eventarc_before_json}" python3 - <<'PY'
331
+ import json
332
+ import os
333
+
334
+ topic = "projects/n8-memory-palace/topics/billing-breaker"
335
+ triggers = json.loads(os.environ["EVENTARC_JSON"])
336
+ if not isinstance(triggers, list):
337
+ raise SystemExit("Eventarc list response is not an array")
338
+ for trigger in triggers:
339
+ pubsub = trigger.get("transport", {}).get("pubsub", {})
340
+ if pubsub.get("topic") != topic:
341
+ continue
342
+ name = trigger.get("name")
343
+ if not isinstance(name, str) or not name.startswith(
344
+ "projects/n8-memory-palace/locations/us-central1/triggers/"
345
+ ):
346
+ raise SystemExit("matching Eventarc trigger name is malformed")
347
+ print(name)
348
+ PY
349
+ )"; then
350
+ echo "Eventarc transport parsing failed; trigger identity is not proved" >&2
351
+ exit 1
352
+ fi
353
+ gcloud functions delete billing-breaker \
354
+ --region=us-central1 --project=n8-memory-palace --quiet || true
355
+ while IFS= read -r trigger_resource; do
356
+ [[ -z "${trigger_resource}" ]] && continue
357
+ gcloud eventarc triggers delete "${trigger_resource##*/}" \
358
+ --location=us-central1 --project=n8-memory-palace --quiet || true
359
+ done <<<"${trigger_rows}"
360
+ gcloud run services delete billing-breaker \
361
+ --region=us-central1 --project=n8-memory-palace --quiet || true
362
+
363
+ if ! function_list="$(
364
+ gcloud functions list --v2 --regions=us-central1 \
365
+ --project=n8-memory-palace --format=json
366
+ )"; then
367
+ echo "function list failed; deletion is not proved" >&2
368
+ exit 1
369
+ fi
370
+ if ! printf '%s' "${function_list}" | python3 deployment_checks.py absent \
371
+ --field=name \
372
+ --value=projects/n8-memory-palace/locations/us-central1/functions/billing-breaker; then
373
+ echo "function validator failed; deletion is not proved" >&2
374
+ exit 1
375
+ fi
376
+ if ! run_service_list="$(
377
+ gcloud run services list --region=us-central1 \
378
+ --project=n8-memory-palace --format=json
379
+ )"; then
380
+ echo "Cloud Run list failed; deletion is not proved" >&2
381
+ exit 1
382
+ fi
383
+ if ! printf '%s' "${run_service_list}" | python3 deployment_checks.py absent \
384
+ --field=metadata.name --value=billing-breaker; then
385
+ echo "Cloud Run validator failed; deletion is not proved" >&2
386
+ exit 1
387
+ fi
388
+ if ! eventarc_after_json="$(
389
+ gcloud eventarc triggers list \
390
+ --location=us-central1 --project=n8-memory-palace --format=json
391
+ )"; then
392
+ echo "Eventarc lookup failed; trigger removal is not proved" >&2
393
+ exit 1
394
+ fi
395
+ if ! printf '%s' "${eventarc_after_json}" | \
396
+ python3 deployment_checks.py eventarc-isolation \
397
+ --topic-resource=projects/n8-memory-palace/topics/billing-breaker \
398
+ --function-resource=projects/n8-memory-palace/locations/us-central1/functions/billing-breaker \
399
+ --run-service-name=billing-breaker \
400
+ --run-service-resource=projects/n8-memory-palace/locations/us-central1/services/billing-breaker; then
401
+ echo "Eventarc validator failed; trigger removal is not proved" >&2
402
+ exit 1
403
+ fi
404
+ while IFS= read -r subscription_resource; do
405
+ [[ -z "${subscription_resource}" ]] && continue
406
+ if ! gcloud pubsub subscriptions delete "${subscription_resource}" --quiet; then
407
+ gcloud pubsub topics detach-subscription "${subscription_resource}" \
408
+ --quiet || true
409
+ fi
410
+ done <<<"${subscription_rows}"
411
+ if ! subscription_after_json="$(
412
+ gcloud pubsub topics list-subscriptions billing-breaker \
413
+ --project=n8-memory-palace --format=json
414
+ )"; then
415
+ echo "topic-side subscription list failed; queue removal is not proved" >&2
416
+ exit 1
417
+ fi
418
+ if ! printf '%s' "${subscription_after_json}" | \
419
+ python3 deployment_checks.py topic-subscriptions --state=empty; then
420
+ echo "a topic-attached subscription remains; queue removal is not proved" >&2
421
+ exit 1
422
+ fi
423
+ ```
424
+
425
+ Stop if any successful list still contains the function, service, matching
426
+ trigger, or topic-attached subscription. Ignored delete/detach status supports
427
+ a partially completed cleanup; it never substitutes for the later list proof.
428
+
429
+ 4. Delete the dedicated topic to destroy the old delivery path. Remove any
430
+ temporary build roles left by a failed apply, verify no D2 project binding
431
+ remains, then delete all three identities. Successful lists must prove every
432
+ exact resource absent:
433
+
434
+ ```sh
435
+ gcloud pubsub topics delete billing-breaker \
436
+ --project=n8-memory-palace --quiet || true
437
+ for role in roles/artifactregistry.writer roles/logging.logWriter roles/storage.objectViewer; do
438
+ gcloud projects remove-iam-policy-binding n8-memory-palace \
439
+ --member="serviceAccount:billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com" \
440
+ --role="${role}" || true
441
+ done
442
+ if ! project_policy="$(
443
+ gcloud projects get-iam-policy n8-memory-palace --format=json
444
+ )"; then
445
+ echo "project policy lookup failed; D2 IAM cleanup is not proved" >&2
446
+ exit 1
447
+ fi
448
+ if ! printf '%s' "${project_policy}" | \
449
+ python3 deployment_checks.py exact-project-role \
450
+ --role=roles/billing.projectManager --state=absent; then
451
+ echo "detach-role validator failed; D2 IAM cleanup is not proved" >&2
452
+ exit 1
453
+ fi
454
+ for role in roles/artifactregistry.writer roles/logging.logWriter roles/storage.objectViewer; do
455
+ if ! printf '%s' "${project_policy}" | \
456
+ python3 deployment_checks.py project-role \
457
+ --role="${role}" \
458
+ --member="serviceAccount:billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com" \
459
+ --state=absent; then
460
+ echo "build-role validator failed; D2 IAM cleanup is not proved" >&2
461
+ exit 1
462
+ fi
463
+ done
464
+ gcloud iam service-accounts delete \
465
+ billing-breaker-runtime@n8-memory-palace.iam.gserviceaccount.com \
466
+ --project=n8-memory-palace --quiet || true
467
+ gcloud iam service-accounts delete \
468
+ billing-breaker-trigger@n8-memory-palace.iam.gserviceaccount.com \
469
+ --project=n8-memory-palace --quiet || true
470
+ gcloud iam service-accounts delete \
471
+ billing-breaker-build@n8-memory-palace.iam.gserviceaccount.com \
472
+ --project=n8-memory-palace --quiet || true
473
+ if ! topic_list="$(
474
+ gcloud pubsub topics list --project=n8-memory-palace --format=json
475
+ )"; then
476
+ echo "topic list failed; deletion is not proved" >&2
477
+ exit 1
478
+ fi
479
+ if ! printf '%s' "${topic_list}" | python3 deployment_checks.py absent \
480
+ --field=name \
481
+ --value=projects/n8-memory-palace/topics/billing-breaker; then
482
+ echo "topic validator failed; deletion is not proved" >&2
483
+ exit 1
484
+ fi
485
+ if ! service_account_list="$(
486
+ gcloud iam service-accounts list \
487
+ --project=n8-memory-palace --format=json
488
+ )"; then
489
+ echo "service-account list failed; deletion is not proved" >&2
490
+ exit 1
491
+ fi
492
+ for service_account in billing-breaker-runtime billing-breaker-trigger billing-breaker-build; do
493
+ if ! printf '%s' "${service_account_list}" | \
494
+ python3 deployment_checks.py absent \
495
+ --field=email \
496
+ --value="${service_account}@n8-memory-palace.iam.gserviceaccount.com"; then
497
+ echo "service-account validator failed; deletion is not proved" >&2
498
+ exit 1
499
+ fi
500
+ done
501
+ ```
502
+
503
+ Stop before identity deletion if any exact IAM absence check fails.
504
+
505
+ 5. Only after steps 1–4 pass, reattach the known open billing account and
506
+ verify both fields:
507
+
508
+ ```sh
509
+ export BILLING_ACCOUNT_ID=000000-000000-000000
510
+ gcloud billing projects link n8-memory-palace \
511
+ --billing-account="${BILLING_ACCOUNT_ID}"
512
+ if ! billing_json="$(
513
+ gcloud billing projects describe n8-memory-palace --format=json
514
+ )"; then
515
+ echo "billing lookup failed; reattachment is not proved" >&2
516
+ exit 1
517
+ fi
518
+ if ! printf '%s' "${billing_json}" | python3 deployment_checks.py billing \
519
+ --billing-account-id="${BILLING_ACCOUNT_ID}"; then
520
+ echo "billing validator failed; reattachment is not proved" >&2
521
+ exit 1
522
+ fi
523
+ ```
524
+
525
+ The validator requires `billingEnabled: true` and exactly
526
+ `billingAccounts/${BILLING_ACCOUNT_ID}`.
527
+
528
+ 6. Inspect Cloud SQL and Cloud Run, restore/redeploy D1 where necessary, and
529
+ repeat its cloud round trip. Reattaching billing does not guarantee every
530
+ resource resumes automatically.
531
+ 7. Rearm only when the current budget period is below its limit (or a new
532
+ period has begun), every old D2 resource is absent, and recovery is verified.
533
+ Run the full preflight and `deploy.sh --apply` again; it will create fresh
534
+ identities and a fresh queue.
535
+
536
+ If `deploy.sh --apply` fails, its trap attempts to revoke applicable detach and
537
+ temporary build roles and then proves their absence from a fresh project-policy
538
+ read. A `CRITICAL` message or exit 99 means the proof failed: assume detach
539
+ authority remains active and disconnect the budget immediately. The script
540
+ deliberately leaves created resources for inspection. Verify billing is still
541
+ enabled, disconnect the budget if it was wired, then perform steps 1–4 before
542
+ any retry. Never work around the script by reusing those resources.
543
+
544
+ Revisit this package when Google Spend Caps becomes generally available and
545
+ covers the project's complete Cloud Run plus Cloud SQL spend surface.