First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:49:01 -04:00
co-authored by Claude Opus 5.5
commit 6062d23480
14 changed files with 2198 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 0.1.0 - 2026-09-28
First public release under MIT.
- `verifyTermination()` checks each enumerated resource and passes only those
confirmed gone by an independent probe.
- Absence reported by the delete call itself, assumed, or unknown is not
assessed, never a pass.
- A separate critical finding fires when compute is gone and billing is still
active.
- Resources kept under a stated retention policy are not applicable and are
still printed.
- `coverageGaps()` lists resource classes the teardown never enumerated, and
`formatTermination()` renders the report with those gaps.
+22
View File
@@ -0,0 +1,22 @@
# Contributing
Issues and merge requests are welcome at
https://gitlab.com/extant2000/kill-switch.
## Ground rules
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
that fails without your fix, and check that it does fail before you submit.
- Measure, do not assume. Two modules with the same line count can be
different programs.
- Keep dependencies minimal. Every new dependency needs a reason.
- Explain why in comments, not what.
## Before you open a merge request
Run both of these and make sure they pass:
```
npm run build
npm test
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Extant 2000 LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+105
View File
@@ -0,0 +1,105 @@
# KillSwitch
Checks that a terminated deployment is actually gone, resource class by
resource class, including the billing that tends to outlive it.
## What it does and why
Deleting is the easy half. The hard half is proving nothing remains, and the
two failures that matter are both invisible to the delete path itself.
### Billing outliving compute
The servers stop, the DNS record goes, and the subscription keeps charging.
Each half looks correct on its own: the teardown succeeded, and the
subscription is in a normal active state. Only the combination is wrong, and
the customer is the one who finds out. No per-resource check can see this,
so KillSwitch reports it as a separate critical finding and prints it first.
### A delete response taken as a verification
A `DELETE` that returns 200 means the API accepted the request. It does not
mean the resource is gone. A teardown whose only evidence is its own success
response has verified nothing, so an `absent` state backed only by
`delete-response` is reported as not assessed. That is the difference
between terminated and verifiably terminated.
KillSwitch never deletes anything. It takes an inventory you enumerated and
decides whether the removal can be evidenced. There is no network access and
no cloud SDK.
| `state` and `verifiedBy` | Result |
|---|---|
| `absent`, `independent-probe` | Met. The only real pass. |
| `absent`, `delete-response` | Not assessed. The API accepted a request. |
| `absent`, `assumed` or unset | Not assessed. An assumption is not a verification. |
| `unknown` | Not assessed. Nobody looked. |
| `present` with `retainedBy` | Not applicable, and still printed. |
| `present` | Not met. This is residue. |
Surviving `billing`, `secret` and `database` resources are critical, because
they cost money or can still authenticate. Other classes are high.
## Retention is an outcome, not a leak
A backup kept after termination under a stated policy is correct behaviour.
Reporting it as residue would teach people to ignore the report. Setting
`retainedBy` makes it not applicable, but it is still printed, because a
retention nobody can see looks the same as a leak.
## Coverage gaps
`coverageGaps()` lists the resource classes the teardown never mentioned. It
answers a different question from "is this resource gone". A checklist that
never mentions billing passes every check it makes. `formatTermination()`
adds these gaps to the report as a NOT COVERED note.
## Usage
```ts
import { verifyTermination, formatTermination, coverageGaps, type Termination } from '@extant2000/kill-switch'
const termination: Termination = {
target: 'customer example-co',
resources: [
{ id: 'web-1', class: 'compute', where: 'cloud console: instances',
state: 'absent', verifiedBy: 'independent-probe' },
{ id: 'sub_123', class: 'billing', where: 'billing API: subscriptions',
state: 'present', verifiedBy: 'independent-probe' },
{ id: 'data-vol', class: 'storage', where: 'DELETE /volumes/data-vol',
state: 'absent', verifiedBy: 'delete-response' },
{ id: 'nightly-backup', class: 'backup', where: 's3://backups/example-co',
state: 'present', retainedBy: '90-day retention, contractual' },
],
}
const report = verifyTermination(termination) // evidence-record report
const gaps = coverageGaps(termination) // ['dns', 'route', 'database', 'mailbox', 'secret']
console.log(formatTermination(termination))
```
Output of the example above, with the header, notes and detail lines
omitted:
```
NOT MET · 4 items examined · 5 findings
✗ CRITICAL Compute is gone and billing is still active [NOT MET]
✗ CRITICAL billing sub_123 still present after termination [NOT MET]
· MEDIUM storage data-vol reported absent by the delete call itself [NOT ASSESSED]
◦ info compute web-1 confirmed gone by independent probe [MET]
```
Resource classes are `compute`, `storage`, `dns`, `route`, `database`,
`billing`, `mailbox`, `secret` and `backup`. An empty inventory is reported
as not assessed: an unenumerated resource is not a deleted one.
## Install
```
npm install @extant2000/kill-switch
```
## License
MIT. See [LICENSE](LICENSE).
+6
View File
@@ -0,0 +1,6 @@
# Security
Please report vulnerabilities privately by email to security@extant2000.com.
Do not open a public issue for a security problem.
We aim to acknowledge every report within 5 business days.
+1503
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@extant2000/kill-switch",
"version": "0.1.0",
"private": false,
"description": "Checks that a terminated deployment is actually gone, including billing that outlives compute.",
"license": "MIT",
"type": "module",
"main": "./dist-cjs/index.js",
"files": [
"dist",
"dist-cjs",
"src",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://gitlab.com/extant2000/kill-switch.git"
},
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist-cjs/index.js"
}
},
"scripts": {
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"dependencies": {
"@extant2000/evidence-record": "^0.1.2"
},
"author": "Extant 2000 LLC",
"homepage": "https://gitlab.com/extant2000/kill-switch",
"bugs": {
"url": "https://gitlab.com/extant2000/kill-switch/-/issues"
}
}
+255
View File
@@ -0,0 +1,255 @@
/**
* KillSwitch — verify that a terminated deployment is actually gone.
*
* Deleting is the easy half. The capability is PROVING nothing remains, and
* the two failures that matter are both invisible to the delete path itself:
*
* 1. **Billing outliving compute.** The containers stop, the DNS record
* goes, and the subscription keeps charging. The customer stopped
* receiving the service and did not stop paying for it. Nothing in the
* teardown script notices, because billing lives in a different system.
*
* 2. **A delete response mistaken for a verification.** `DELETE` returning
* 200 means the API accepted the request. It does not mean the resource
* is gone — and a teardown whose only evidence is its own success
* response has verified nothing. This library treats that as UNVERIFIED,
* not as absent, which is the entire difference between "terminated" and
* "verifiably terminated".
*
* Deliberate retention is a first-class outcome, not a leak: backups that
* outlive a termination under a stated policy are correct, and reporting
* them as residue would train people to ignore the report.
*
* Pure: takes an enumerated inventory, returns findings. No network, no
* cloud SDK, no deletes — this library never removes anything, it only
* decides whether a removal can be evidenced.
*/
import {
type CapabilityReport,
type Finding,
type FormatOptions,
type Severity,
evidence,
formatReport as renderReport,
} from '@extant2000/evidence-record'
/**
* Classes of resource a deployment leaves behind.
*
* `billing` and `secret` are listed alongside the infrastructure on purpose.
* A teardown checklist that covers only compute and storage is the one that
* produces a cancelled customer who is still being charged.
*/
export type ResourceClass =
| 'compute'
| 'storage'
| 'dns'
| 'route'
| 'database'
| 'billing'
| 'mailbox'
| 'secret'
| 'backup'
export type ResourceState =
/** Confirmed still there. */
| 'present'
/** Confirmed gone. */
| 'absent'
/** Not checked, or the check could not answer. Never counted as gone. */
| 'unknown'
/**
* How `state` was established.
*
* `delete-response` is the weak one and the common one: the teardown called
* DELETE, got a 2xx, and recorded success. That is a statement about the
* API accepting a request, not about the resource.
*/
export type VerificationMethod = 'independent-probe' | 'delete-response' | 'assumed'
export interface Resource {
id: string
class: ResourceClass
/** Where this was observed — a command, a console URL, a config path. */
where: string
state: ResourceState
verifiedBy?: VerificationMethod
/**
* A stated policy that legitimately keeps this alive past termination,
* e.g. "90-day backup retention, contractual". Presence WITH a retention
* reason is correct behaviour, not residue.
*/
retainedBy?: string
}
export interface Termination {
/** What was terminated, e.g. "customer acme-corp". */
target: string
resources: Resource[]
}
/** Classes whose survival is a security or money problem, not just untidiness. */
const CRITICAL_CLASSES: ResourceClass[] = ['billing', 'secret', 'database']
function resourceEvidence(r: Resource) {
return evidence.file(r.where, undefined, `${r.class}: ${r.state}`,
r.verifiedBy ? `verified by ${r.verifiedBy}` : 'no verification method recorded')
}
function severityFor(r: Resource): Severity {
return CRITICAL_CLASSES.includes(r.class) ? 'critical' : 'high'
}
function findingFor(r: Resource): Finding {
const base = { id: `${r.class}:${r.id}`, evidence: [resourceEvidence(r)] }
if (r.state === 'present' && r.retainedBy) {
return {
...base,
summary: `${r.class} ${r.id} still present, retained deliberately`,
determination: 'not-applicable',
severity: 'info',
detail: `Retained by: ${r.retainedBy}. This is policy, not residue — but it is printed, because a retention nobody can see is indistinguishable from a leak.`,
}
}
if (r.state === 'present') {
return {
...base,
summary: `${r.class} ${r.id} still present after termination`,
determination: 'fail',
severity: severityFor(r),
detail: r.class === 'billing'
? 'The subscription is still active. The customer has stopped receiving the service and has not stopped paying for it.'
: r.class === 'secret'
? 'A credential outlived the deployment it belonged to. It can still authenticate.'
: 'No retention policy explains this. It is residue.',
}
}
if (r.state === 'unknown') {
return {
...base,
summary: `${r.class} ${r.id} was not checked`,
determination: 'not-assessed',
severity: severityFor(r),
detail: 'A resource nobody looked at is not a resource that was removed.',
}
}
// state === 'absent' — but on whose word?
if (r.verifiedBy === 'delete-response') {
return {
...base,
summary: `${r.class} ${r.id} reported absent by the delete call itself`,
determination: 'not-assessed',
severity: 'medium',
detail: 'A 2xx from DELETE means the API accepted the request, not that the resource is gone. Re-check it from outside the teardown path.',
}
}
if (r.verifiedBy === 'assumed' || !r.verifiedBy) {
return {
...base,
summary: `${r.class} ${r.id} assumed absent, with no check recorded`,
determination: 'not-assessed',
severity: severityFor(r),
detail: 'Nothing observed this resource. An assumption is not a verification.',
}
}
return {
...base,
summary: `${r.class} ${r.id} confirmed gone by independent probe`,
determination: 'pass',
severity: 'info',
}
}
/**
* The cross-cutting check no per-resource rule can make.
*
* Compute gone + billing alive is the failure that costs money and trust,
* and it is invisible to both halves individually: the infrastructure
* teardown succeeded, and the subscription is in a perfectly normal active
* state. Only the combination is wrong.
*/
function billingOutlivesCompute(t: Termination): Finding | null {
const computeGone = t.resources
.filter(r => r.class === 'compute')
.every(r => r.state === 'absent')
const hasCompute = t.resources.some(r => r.class === 'compute')
const liveBilling = t.resources.filter(
r => r.class === 'billing' && r.state === 'present' && !r.retainedBy,
)
if (!hasCompute || !computeGone || liveBilling.length === 0) return null
return {
id: 'billing-outlives-compute',
summary: 'Compute is gone and billing is still active',
determination: 'fail',
severity: 'critical',
detail: 'Each half looks correct on its own: the teardown succeeded, and the subscription is in a normal active state. Only the combination is wrong, and the customer is the one who finds out.',
evidence: liveBilling.map(resourceEvidence),
}
}
/** Check whether a termination can be evidenced, class by class. */
export function verifyTermination(t: Termination): CapabilityReport {
const resources = t.resources ?? []
if (resources.length === 0) {
return {
capability: 'KillSwitch',
scope: `${t.target} — nothing enumerated`,
examined: 0,
findings: [],
notes: ['No resources were enumerated, so nothing was verified. An unenumerated resource is not a deleted one.'],
}
}
const cross = billingOutlivesCompute(t)
return {
capability: 'KillSwitch',
scope: `${t.target} — ${resources.length} resource(s)`,
examined: resources.length,
findings: [...(cross ? [cross] : []), ...resources.map(findingFor)],
notes: [`classes covered: ${[...new Set(resources.map(r => r.class))].sort().join(', ')}`],
}
}
/**
* Classes present in the termination, against the classes a complete
* teardown should cover.
*
* Reported separately from the resource findings because it is a different
* question: not "is this resource gone" but "did anyone think about DNS at
* all". A checklist that never mentions billing passes every check it makes.
*/
export function coverageGaps(t: Termination, expected: ResourceClass[] = ALL_CLASSES): ResourceClass[] {
const seen = new Set((t.resources ?? []).map(r => r.class))
return expected.filter(c => !seen.has(c))
}
export const ALL_CLASSES: ResourceClass[] = [
'compute', 'storage', 'dns', 'route', 'database', 'billing', 'mailbox', 'secret', 'backup',
]
/** Render a termination check for a terminal, CI log or evidence package. */
export function formatTermination(t: Termination, opts: FormatOptions = {}): string {
const report = verifyTermination(t)
const gaps = coverageGaps(t)
if (gaps.length > 0 && (t.resources ?? []).length > 0) {
report.notes = [
...(report.notes ?? []),
`NOT COVERED: ${gaps.join(', ')} — a class nobody enumerated cannot fail a check.`,
]
}
return renderReport(report, opts)
}
export { CRITICAL_CLASSES }
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest'
import { overall, validateReport } from '@extant2000/evidence-record'
import {
coverageGaps,
formatTermination,
verifyTermination,
type Resource,
type Termination,
} from '../src/index.js'
const gone = (id: string, cls: Resource['class']): Resource =>
({ id, class: cls, where: `probe:${id}`, state: 'absent', verifiedBy: 'independent-probe' })
describe('billing outliving compute — the failure that costs money', () => {
const t: Termination = {
target: 'customer acme-corp',
resources: [
gone('acme-web', 'compute'),
gone('acme.example.com', 'dns'),
{ id: 'sub_1ABC', class: 'billing', where: 'stripe:sub_1ABC', state: 'present', verifiedBy: 'independent-probe' },
],
}
it('catches the combination neither half can see', () => {
// The teardown succeeded. The subscription is in a normal active state.
// Only together are they wrong.
const r = verifyTermination(t)
const cross = r.findings.find(f => f.id === 'billing-outlives-compute')!
expect(cross.determination).toBe('fail')
expect(cross.severity).toBe('critical')
expect(overall(r)).toBe('fail')
})
it('leads with it, above the per-resource findings', () => {
const text = formatTermination(t)
expect(text.indexOf('Compute is gone and billing is still active'))
.toBeLessThan(text.indexOf('sub_1ABC still present'))
})
it('says who finds out', () => {
expect(formatTermination(t)).toContain('the customer is the one who finds out')
})
it('does not fire when compute is still up — that is just a running service', () => {
const running: Termination = {
target: 'x',
resources: [
{ id: 'web', class: 'compute', where: 'docker ps', state: 'present' },
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present' },
],
}
expect(verifyTermination(running).findings.some(f => f.id === 'billing-outlives-compute')).toBe(false)
})
it('does not fire when the subscription is deliberately retained', () => {
const retained: Termination = {
target: 'x',
resources: [
gone('web', 'compute'),
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present', retainedBy: 'final invoice pending' },
],
}
expect(verifyTermination(retained).findings.some(f => f.id === 'billing-outlives-compute')).toBe(false)
})
})
describe('a delete response is not a verification', () => {
it('treats absence claimed by the delete call as UNVERIFIED, not gone', () => {
// A 2xx from DELETE means the API accepted the request. This is the
// entire difference between "terminated" and "verifiably terminated".
const r = verifyTermination({
target: 'x',
resources: [{ id: 'vol-1', class: 'storage', where: 'DELETE /volumes/vol-1', state: 'absent', verifiedBy: 'delete-response' }],
})
expect(r.findings[0]!.determination).toBe('not-assessed')
expect(overall(r)).toBe('not-assessed')
expect(formatTermination({
target: 'x',
resources: [{ id: 'vol-1', class: 'storage', where: 'DELETE /volumes/vol-1', state: 'absent', verifiedBy: 'delete-response' }],
})).toContain('Re-check it from outside the teardown path')
})
it('treats an unrecorded method as an assumption, never a pass', () => {
const r = verifyTermination({
target: 'x',
resources: [{ id: 'v', class: 'storage', where: 'runbook.md', state: 'absent' }],
})
expect(r.findings[0]!.determination).toBe('not-assessed')
expect(r.findings[0]!.detail).toContain('An assumption is not a verification')
})
it('passes only on an independent probe', () => {
expect(overall(verifyTermination({ target: 'x', resources: [gone('v', 'storage')] }))).toBe('pass')
})
it('never counts unknown as gone', () => {
const r = verifyTermination({
target: 'x',
resources: [{ id: 'k', class: 'secret', where: 'vault', state: 'unknown' }],
})
expect(r.findings[0]!.determination).toBe('not-assessed')
expect(r.findings[0]!.severity).toBe('critical')
})
})
describe('retention is an outcome, not a leak', () => {
const t: Termination = {
target: 'x',
resources: [{
id: 'backup-2026-07', class: 'backup', where: 'b2://bucket/acme',
state: 'present', retainedBy: '90-day retention, contractual',
}],
}
it('reports a policy-retained resource as not-applicable, not a failure', () => {
expect(verifyTermination(t).findings[0]!.determination).toBe('not-applicable')
expect(overall(verifyTermination(t))).toBe('not-applicable')
})
it('still prints it, because an invisible retention looks like a leak', () => {
expect(formatTermination(t)).toContain('90-day retention, contractual')
})
})
describe('residue', () => {
it('rates a surviving secret critical, and says it can still authenticate', () => {
const r = verifyTermination({
target: 'x',
resources: [{ id: 'API_KEY', class: 'secret', where: '.env', state: 'present' }],
})
expect(r.findings[0]!.severity).toBe('critical')
expect(r.findings[0]!.detail).toContain('can still authenticate')
})
it('rates a surviving DNS record high rather than critical', () => {
const r = verifyTermination({
target: 'x',
resources: [{ id: 'a.example.com', class: 'dns', where: 'cf', state: 'present' }],
})
expect(r.findings[0]!.severity).toBe('high')
})
})
describe('coverage — a class nobody enumerated cannot fail a check', () => {
it('names the classes the teardown never mentioned', () => {
const t: Termination = { target: 'x', resources: [gone('web', 'compute'), gone('v', 'storage')] }
expect(coverageGaps(t)).toContain('billing')
expect(coverageGaps(t)).toContain('secret')
expect(formatTermination(t)).toContain('NOT COVERED')
})
it('reports no gaps when every class is enumerated', () => {
const t: Termination = {
target: 'x',
resources: (['compute', 'storage', 'dns', 'route', 'database', 'billing', 'mailbox', 'secret', 'backup'] as const)
.map(c => gone(c, c)),
}
expect(coverageGaps(t)).toEqual([])
expect(formatTermination(t)).not.toContain('NOT COVERED')
})
})
describe('conformance with the evidence-record standard', () => {
it('an empty termination is not-assessed, never clean', () => {
const r = verifyTermination({ target: 'x', resources: [] })
expect(overall(r)).toBe('not-assessed')
expect(formatTermination({ target: 'x', resources: [] }))
.toContain('An unenumerated resource is not a deleted one')
})
it('every conclusion carries a citation', () => {
const t: Termination = {
target: 'x',
resources: [
gone('web', 'compute'),
{ id: 'sub_1', class: 'billing', where: 'stripe', state: 'present' },
],
}
expect(validateReport(verifyTermination(t))).toEqual([])
})
})
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist-cjs",
"declaration": false
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
"declaration": true, "outDir": "dist", "rootDir": "src",
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}