@botiverse/k-carrier 0.1.0
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.
- package/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- package/package.json +43 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fake-host teeth (harness-design §1.1 first half; the package spec:
|
|
3
|
+
* ledger equivalence incl. after rollback, and every fault switch really
|
|
4
|
+
* turning its tooth red — switch off, tooth green).
|
|
5
|
+
*
|
|
6
|
+
* Registration site only — check bodies live in fake-host/checks.ts;
|
|
7
|
+
* importing this module registers the teeth.
|
|
8
|
+
*/
|
|
9
|
+
import { registerTooth } from "./registry.ts";
|
|
10
|
+
import {
|
|
11
|
+
checkLedgerEquivalence,
|
|
12
|
+
checkLedgerEquivalenceAfterRollback,
|
|
13
|
+
checkQuiesceCompletes,
|
|
14
|
+
checkStopCompletes,
|
|
15
|
+
checkProbeVersionMatchesSlot,
|
|
16
|
+
checkProbeBindsCurrentIncarnation,
|
|
17
|
+
checkStartCompletes,
|
|
18
|
+
} from "../fake-host/checks.ts";
|
|
19
|
+
|
|
20
|
+
registerTooth({
|
|
21
|
+
id: "fake-host.ledger-equivalence",
|
|
22
|
+
profiles: ["service"],
|
|
23
|
+
layers: ["L2"],
|
|
24
|
+
kind: { kind: "invariant" },
|
|
25
|
+
mustRed: [
|
|
26
|
+
{
|
|
27
|
+
mutate: "resume() rewrites the ledger differently than quiesce parked",
|
|
28
|
+
caughtOnlyBy: "this", // only this tooth compares parked vs resumed bytes on the direct path
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
mutate: "quiesce() does not durably park the workload (ledger lost)",
|
|
32
|
+
caughtOnlyBy: "this",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
run: checkLedgerEquivalence,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
registerTooth({
|
|
39
|
+
id: "fake-host.ledger-equivalence-after-rollback",
|
|
40
|
+
profiles: ["service"],
|
|
41
|
+
layers: ["L2"],
|
|
42
|
+
kind: { kind: "invariant" },
|
|
43
|
+
mustRed: [
|
|
44
|
+
{
|
|
45
|
+
mutate: "resume() after rollback restores a different ledger",
|
|
46
|
+
caughtOnlyBy: "this", // the rollback path is only exercised by this tooth
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
run: checkLedgerEquivalenceAfterRollback,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
registerTooth({
|
|
53
|
+
id: "fake-host.fault-fail-on-quiesce",
|
|
54
|
+
profiles: ["service"],
|
|
55
|
+
layers: ["L2"],
|
|
56
|
+
kind: { kind: "invariant" },
|
|
57
|
+
mustRed: [
|
|
58
|
+
{
|
|
59
|
+
mutate: "quiesce() fails to park even when fail-on-quiesce is off",
|
|
60
|
+
caughtOnlyBy: "this", // only this tooth pins quiesce's normal completion
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
run: checkQuiesceCompletes,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
registerTooth({
|
|
67
|
+
id: "fake-host.fault-hang-on-stop",
|
|
68
|
+
profiles: ["service"],
|
|
69
|
+
layers: ["L2"],
|
|
70
|
+
kind: { kind: "invariant" },
|
|
71
|
+
mustRed: [
|
|
72
|
+
{
|
|
73
|
+
mutate: "stop() never completes even when hang-on-stop is off",
|
|
74
|
+
caughtOnlyBy: "this", // only this tooth races stop against a clock window
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
run: checkStopCompletes,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
registerTooth({
|
|
81
|
+
id: "fake-host.fault-wrong-version-probe",
|
|
82
|
+
profiles: ["service"],
|
|
83
|
+
layers: ["L2", "L3"],
|
|
84
|
+
kind: { kind: "invariant" },
|
|
85
|
+
mustRed: [
|
|
86
|
+
{
|
|
87
|
+
mutate: "healthProbe() reports a wrong version even when the switch is off",
|
|
88
|
+
caughtOnlyBy: "this", // probe veracity per slot is pinned only here
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
run: checkProbeVersionMatchesSlot,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
registerTooth({
|
|
95
|
+
id: "fake-host.fault-stale-startid-probe",
|
|
96
|
+
profiles: ["service"],
|
|
97
|
+
layers: ["L2", "L3"],
|
|
98
|
+
kind: { kind: "invariant" },
|
|
99
|
+
mustRed: [
|
|
100
|
+
{
|
|
101
|
+
mutate: "healthProbe() reports a stale startId even when the switch is off",
|
|
102
|
+
caughtOnlyBy: "this", // incarnation binding is pinned only here
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
run: checkProbeBindsCurrentIncarnation,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
registerTooth({
|
|
109
|
+
id: "fake-host.fault-crash-during-start",
|
|
110
|
+
profiles: ["service"],
|
|
111
|
+
layers: ["L2"],
|
|
112
|
+
kind: { kind: "invariant" },
|
|
113
|
+
mustRed: [
|
|
114
|
+
{
|
|
115
|
+
mutate: "start() throws even when crash-during-start is off",
|
|
116
|
+
caughtOnlyBy: "this", // only this tooth pins start's normal completion
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
run: checkStartCompletes,
|
|
120
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teeth registration index — importing this module registers every tooth
|
|
3
|
+
* in the harness (the side effect the k-harness runner and CI rely on).
|
|
4
|
+
* Tests import their own modules; this is the single entry for the CLI.
|
|
5
|
+
*/
|
|
6
|
+
import "./m0.ts";
|
|
7
|
+
import "./artifactFactory.ts";
|
|
8
|
+
import "./fakeHost.ts";
|
|
9
|
+
import "./selfCheck.ts";
|
|
10
|
+
import "./examples.ts";
|
|
11
|
+
import "./blackbox.ts";
|
|
12
|
+
import "./artifact.ts";
|
|
13
|
+
import "./artifactM5.ts";
|
|
14
|
+
import "./artifactM6.ts";
|
|
15
|
+
import "./artifactDownload.ts";
|
|
16
|
+
import "./sim.ts";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M0 teeth — fake-server + scenario sandbox acceptance (test-plan M0 rows:
|
|
3
|
+
* "fake 静态 server(manifest+工件+签名,可篡改)| 正常链路可走通 | 篡改任一字节
|
|
4
|
+
* ⇒ 下游校验齿红"; harness-design §1.2/§1.3).
|
|
5
|
+
*
|
|
6
|
+
* The check bodies live in checks.ts (imported here); this module is the
|
|
7
|
+
* registration site — importing it registers every M0 tooth, and the
|
|
8
|
+
* registry forces profiles, invariant/baseline dichotomy and an answered
|
|
9
|
+
* must-red list (harness-design §1.5). Tests import this module for the
|
|
10
|
+
* side effect and drive known-green/known-red through the exported checks.
|
|
11
|
+
*/
|
|
12
|
+
import { registerTooth } from "./registry.ts";
|
|
13
|
+
import {
|
|
14
|
+
checkServesConsistentRelease,
|
|
15
|
+
checkCorruptByteRejects,
|
|
16
|
+
checkSwapArtifactsRejects,
|
|
17
|
+
checkServeOlderVersion,
|
|
18
|
+
checkDropFileRemoves,
|
|
19
|
+
checkSandboxIsolation,
|
|
20
|
+
checkSandboxVerifyDead,
|
|
21
|
+
} from "./checks.ts";
|
|
22
|
+
|
|
23
|
+
registerTooth({
|
|
24
|
+
id: "fake-server.serves-consistent-release",
|
|
25
|
+
profiles: ["swap", "service"],
|
|
26
|
+
layers: ["L0"],
|
|
27
|
+
kind: { kind: "invariant" },
|
|
28
|
+
mustRed: [
|
|
29
|
+
{
|
|
30
|
+
mutate: "publish writes a manifest whose sha256 is not the artifact's",
|
|
31
|
+
caughtOnlyBy: "this", // no other tooth checks publish-time manifest/bytes agreement
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
mutate: "point the manifest target at a different artifact than the published one",
|
|
35
|
+
caughtOnlyBy: {
|
|
36
|
+
alsoCaughtBy: "core artifact L0 sha256 integrity check (when landed)",
|
|
37
|
+
whyStillNeeded:
|
|
38
|
+
"this tooth is the harness's own上岗证: it pins the fake-server publish/serve wiring now, before any core exists",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
run: checkServesConsistentRelease,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
registerTooth({
|
|
46
|
+
id: "fake-server.tamper-corrupt-byte",
|
|
47
|
+
profiles: ["swap", "service"],
|
|
48
|
+
layers: ["L0"],
|
|
49
|
+
kind: { kind: "invariant" },
|
|
50
|
+
mustRed: [
|
|
51
|
+
{
|
|
52
|
+
mutate: "corruptByte is a no-op that leaves the served bytes unchanged",
|
|
53
|
+
caughtOnlyBy: "this", // only this tooth asserts the tamper had a verifiable effect
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
run: checkCorruptByteRejects,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
registerTooth({
|
|
60
|
+
id: "fake-server.tamper-swap-artifacts",
|
|
61
|
+
profiles: ["swap", "service"],
|
|
62
|
+
layers: ["L0"],
|
|
63
|
+
kind: { kind: "invariant" },
|
|
64
|
+
mustRed: [
|
|
65
|
+
{
|
|
66
|
+
mutate: "swapFiles serves each artifact its own bytes back",
|
|
67
|
+
caughtOnlyBy: "this", // only this tooth asserts the swap is a real cross-swap
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
run: checkSwapArtifactsRejects,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
registerTooth({
|
|
74
|
+
id: "fake-server.tamper-serve-older-version",
|
|
75
|
+
profiles: ["swap", "service"],
|
|
76
|
+
layers: ["L0"],
|
|
77
|
+
kind: { kind: "invariant" },
|
|
78
|
+
mustRed: [
|
|
79
|
+
{
|
|
80
|
+
mutate: "serveOlderVersion keeps serving the current version",
|
|
81
|
+
caughtOnlyBy: "this", // only this tooth asserts the downgrade actually downgrades
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
run: checkServeOlderVersion,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
registerTooth({
|
|
88
|
+
id: "fake-server.tamper-drop-file",
|
|
89
|
+
profiles: ["swap", "service"],
|
|
90
|
+
layers: ["L0"],
|
|
91
|
+
kind: { kind: "invariant" },
|
|
92
|
+
mustRed: [
|
|
93
|
+
{
|
|
94
|
+
mutate: "dropFile keeps serving the dropped file",
|
|
95
|
+
caughtOnlyBy: "this", // only this tooth asserts the file is truly gone for clients
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
run: checkDropFileRemoves,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
registerTooth({
|
|
102
|
+
id: "scenario.sandbox-verify-dead",
|
|
103
|
+
profiles: ["swap", "service"],
|
|
104
|
+
layers: ["L0"],
|
|
105
|
+
kind: { kind: "invariant" },
|
|
106
|
+
mustRed: [
|
|
107
|
+
{
|
|
108
|
+
mutate: "teardown sends kill but never scans/verifies death (residual marker process survives)",
|
|
109
|
+
caughtOnlyBy: "this", // only this tooth proves "发了 kill"≠"死了" at the sandbox boundary
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
run: checkSandboxVerifyDead,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
registerTooth({
|
|
116
|
+
id: "scenario.sandbox-isolation",
|
|
117
|
+
profiles: ["swap", "service"],
|
|
118
|
+
layers: ["L0"],
|
|
119
|
+
kind: { kind: "invariant" },
|
|
120
|
+
mustRed: [
|
|
121
|
+
{
|
|
122
|
+
mutate: "two live sandboxes share the same temp dir",
|
|
123
|
+
caughtOnlyBy: "this", // harness-internal discipline; no downstream check sees the dirs
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
mutate: "two live sandboxes share the same port",
|
|
127
|
+
caughtOnlyBy: "this", // parallel-scenario collisions are invisible to any product tooth
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
mutate: "teardown leaves the sandbox dir on disk",
|
|
131
|
+
caughtOnlyBy: "this", // "沙箱边界即清场边界"; nothing downstream re-checks残留
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
run: checkSandboxIsolation,
|
|
135
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Teeth registry — the discipline carrier of the harness (harness-design §1.5).
|
|
6
|
+
*
|
|
7
|
+
* A tooth is a named, profile-tagged check with a declared way to fail.
|
|
8
|
+
* Registration IS the enforcement point: a tooth missing any discipline
|
|
9
|
+
* field (profile tags, invariant/baseline dichotomy, must-red list, layer
|
|
10
|
+
* declaration) is rejected at registration time with a typed error — it
|
|
11
|
+
* never becomes a silently weaker test.
|
|
12
|
+
*
|
|
13
|
+
* Rules enforced here (each has a test):
|
|
14
|
+
* - unique, well-formed id
|
|
15
|
+
* - non-empty profile set
|
|
16
|
+
* - kind = invariant | baseline-with-failure-condition (no untagged
|
|
17
|
+
* implementation-locking assertions — the invariant/baseline dichotomy)
|
|
18
|
+
* - must-red: >= 1 entry, and each entry answers "who else would catch
|
|
19
|
+
* this mutation?" — "this" (nobody else) or a justified alsoCaughtBy
|
|
20
|
+
* - profile/layer tiering: a tooth may only be tagged with profiles whose
|
|
21
|
+
* layer set covers every layer the tooth exercises (the tier-boundary
|
|
22
|
+
* rule: a cli-profile tooth exercising L2 is a registration error)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A profile is a PROCESS MODEL, defined by cardinality: how many live
|
|
27
|
+
* incarnations K itself manages.
|
|
28
|
+
*
|
|
29
|
+
* swap 0 — K replaces bytes and touches no process. The user may run N
|
|
30
|
+
* concurrently at mixed versions; that is normal and invisible
|
|
31
|
+
* to K. (A one-shot CLI and a long-running interactive session
|
|
32
|
+
* are the SAME here — surprising, but correct: neither has a
|
|
33
|
+
* process K hands over.)
|
|
34
|
+
* service 1 — K stops one incarnation and starts another, briefly 0.
|
|
35
|
+
*
|
|
36
|
+
* There is no third model. Workload preservation, OS lifecycle convergence and
|
|
37
|
+
* fleet drive are CAPABILITIES an adopter opts into, not another kind of
|
|
38
|
+
* process — bundling them as a profile confused "what the app does" with "what
|
|
39
|
+
* K does", which is not K's business.
|
|
40
|
+
*/
|
|
41
|
+
export type Profile = "swap" | "service";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Opt-in capabilities, declared separately from the process model.
|
|
45
|
+
*
|
|
46
|
+
* A capability only belongs here once at least one tooth declares it.
|
|
47
|
+
* Selecting a capability that no tooth answers adds ZERO checks while looking
|
|
48
|
+
* like added assurance -- the empty-suite false-green this harness exists to
|
|
49
|
+
* prevent. `workload-preservation` was removed on those grounds (08-05): no
|
|
50
|
+
* tooth claimed it, and example-host, the host it was imagined for, restarts
|
|
51
|
+
* its workloads rather than preserving them (resume is enough).
|
|
52
|
+
* `core/src/invariants.ts` still exports `workloadPreserved` for a host that
|
|
53
|
+
* genuinely has continuity; it just is not a capability you can select yet.
|
|
54
|
+
*/
|
|
55
|
+
export type Capability =
|
|
56
|
+
| "lifecycle-convergence" // OS lifecycle surfaces must read back (L3 platform)
|
|
57
|
+
| "fleet-drive"; // server-pushed commands, policy-gated (L5)
|
|
58
|
+
|
|
59
|
+
export const ALL_CAPABILITIES: readonly Capability[] = [
|
|
60
|
+
"lifecycle-convergence",
|
|
61
|
+
"fleet-drive",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** L1p = the simplified cli-profile slot model ("swap is promote"). */
|
|
65
|
+
/**
|
|
66
|
+
* Design-doc layers a tooth exercises. L0.5 (supply chain / signing) was
|
|
67
|
+
* removed with the feature on 2026-08-06 and is deliberately absent: a tag
|
|
68
|
+
* for a layer that does not exist makes a tooth look like it covers ground
|
|
69
|
+
* nobody is standing on.
|
|
70
|
+
*/
|
|
71
|
+
export type Layer = "L0" | "L1p" | "L1" | "L2" | "L3" | "L4" | "L5";
|
|
72
|
+
|
|
73
|
+
/** Which layers each profile is allowed to exercise (design §2.5). */
|
|
74
|
+
/**
|
|
75
|
+
* Profiles name PROCESS MODELS, not program archetypes:
|
|
76
|
+
* swap no live process K hands over. Bytes are replaced and take effect
|
|
77
|
+
* on the next start. Several old-version processes may keep running
|
|
78
|
+
* concurrently — that is normal, and invisible to K.
|
|
79
|
+
* service exactly one live incarnation is handed over: stop old, start new,
|
|
80
|
+
* prove it. This is where never-dual-run has meaning.
|
|
81
|
+
* hosted a service that also holds someone else's work (sessions/jobs) and
|
|
82
|
+
* OS lifecycle state that must converge.
|
|
83
|
+
*/
|
|
84
|
+
export const PROFILE_LAYERS: Record<Profile, ReadonlySet<Layer>> = {
|
|
85
|
+
swap: new Set<Layer>(["L0", "L1p"]),
|
|
86
|
+
service: new Set<Layer>(["L0", "L1p", "L1", "L2", "L3", "L4", "L5"]),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type ToothKind =
|
|
90
|
+
| { kind: "invariant" }
|
|
91
|
+
| { kind: "baseline"; failureCondition: string };
|
|
92
|
+
|
|
93
|
+
export type CaughtOnlyBy =
|
|
94
|
+
| "this"
|
|
95
|
+
| { alsoCaughtBy: string; whyStillNeeded: string };
|
|
96
|
+
|
|
97
|
+
export interface MustRed {
|
|
98
|
+
/** The mutation that must turn this tooth red, stated concretely. */
|
|
99
|
+
mutate: string;
|
|
100
|
+
/** "this" = nobody else catches it; otherwise justify why the tooth still earns its keep. */
|
|
101
|
+
caughtOnlyBy: CaughtOnlyBy;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Execution context handed to a tooth run. Grows with the harness. */
|
|
105
|
+
export interface ToothContext {
|
|
106
|
+
profile: Profile;
|
|
107
|
+
sandboxDir: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ToothSpec {
|
|
111
|
+
id: string;
|
|
112
|
+
profiles: Profile[];
|
|
113
|
+
/**
|
|
114
|
+
* Only runs when the adopter has opted into this capability. Absent = the
|
|
115
|
+
* tooth applies to every adopter at these profiles.
|
|
116
|
+
*/
|
|
117
|
+
requiresCapability?: Capability;
|
|
118
|
+
layers: Layer[];
|
|
119
|
+
kind: ToothKind;
|
|
120
|
+
mustRed: MustRed[];
|
|
121
|
+
run: (ctx: ToothContext) => Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Where registerTooth was called, as `file:line`. Captured from the stack
|
|
124
|
+
* at registration, never written by hand: an explicit field would be one
|
|
125
|
+
* more thing to forget, and a stale pointer to a test case is worse than
|
|
126
|
+
* none. Presentation only -- `k-harness --list` prints it so "which cases
|
|
127
|
+
* exist and where do I read one" is answerable without grepping.
|
|
128
|
+
*/
|
|
129
|
+
registeredAt?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type RegistrationErrorCode =
|
|
133
|
+
| "BAD_ID"
|
|
134
|
+
| "DUPLICATE_ID"
|
|
135
|
+
| "NO_PROFILES"
|
|
136
|
+
| "NO_LAYERS"
|
|
137
|
+
| "BASELINE_WITHOUT_FAILURE_CONDITION"
|
|
138
|
+
| "NO_MUST_RED"
|
|
139
|
+
| "MUST_RED_UNANSWERED"
|
|
140
|
+
| "TIER_BOUNDARY";
|
|
141
|
+
|
|
142
|
+
export class ToothRegistrationError extends Error {
|
|
143
|
+
readonly code: RegistrationErrorCode;
|
|
144
|
+
|
|
145
|
+
constructor(code: RegistrationErrorCode, message: string) {
|
|
146
|
+
super(`[${code}] ${message}`);
|
|
147
|
+
this.name = "ToothRegistrationError";
|
|
148
|
+
this.code = code;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ID_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
153
|
+
|
|
154
|
+
const registry = new Map<string, ToothSpec>();
|
|
155
|
+
|
|
156
|
+
/** The first stack frame outside this file = the registration site. */
|
|
157
|
+
function callerSite(): string | undefined {
|
|
158
|
+
const stack = new Error().stack?.split("\n").slice(1) ?? [];
|
|
159
|
+
for (const frame of stack) {
|
|
160
|
+
// Three frame spellings across platforms/loaders: `file:///D:/x/y.ts:12:3`
|
|
161
|
+
// (ESM, note the drive-letter colon INSIDE the path), `/home/x/y.ts:12:3`
|
|
162
|
+
// (POSIX), `D:\x\y.ts:12:3` (win32 CJS). The path part is non-greedy so
|
|
163
|
+
// the :line:col tail — not the drive colon — anchors the split; a plain
|
|
164
|
+
// `file://` string-strip would leave `/D:/x/y.ts`, which win32
|
|
165
|
+
// path.relative mangles into `..\..\D:\...` garbage.
|
|
166
|
+
const m = /\(?((?:file:\/\/)?\/[^\s()]+?|[A-Za-z]:[\\/][^\s()]+?):(\d+):\d+\)?$/.exec(frame.trim());
|
|
167
|
+
if (!m) continue;
|
|
168
|
+
const raw = m[1]!;
|
|
169
|
+
const file = raw.startsWith("file://") ? fileURLToPath(raw) : raw;
|
|
170
|
+
if (file.replaceAll("\\", "/").endsWith("/registry.ts")) continue;
|
|
171
|
+
// Forward slashes in the recorded site: stable to parse and display
|
|
172
|
+
// everywhere, and node opens `/`-separated relative paths on win32 too.
|
|
173
|
+
return `${path.relative(process.cwd(), file).replaceAll("\\", "/")}:${m[2]}`;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function registerTooth(spec: ToothSpec): void {
|
|
179
|
+
if (!spec.id || !ID_RE.test(spec.id)) {
|
|
180
|
+
throw new ToothRegistrationError(
|
|
181
|
+
"BAD_ID",
|
|
182
|
+
`tooth id ${JSON.stringify(spec.id)} must be non-empty dotted/kebab lower-case`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (registry.has(spec.id)) {
|
|
186
|
+
throw new ToothRegistrationError("DUPLICATE_ID", `tooth ${spec.id} already registered`);
|
|
187
|
+
}
|
|
188
|
+
if (!spec.profiles || spec.profiles.length === 0) {
|
|
189
|
+
throw new ToothRegistrationError("NO_PROFILES", `tooth ${spec.id} declares no profiles`);
|
|
190
|
+
}
|
|
191
|
+
if (!spec.layers || spec.layers.length === 0) {
|
|
192
|
+
throw new ToothRegistrationError("NO_LAYERS", `tooth ${spec.id} declares no layers`);
|
|
193
|
+
}
|
|
194
|
+
if (spec.kind.kind === "baseline" && !spec.kind.failureCondition.trim()) {
|
|
195
|
+
throw new ToothRegistrationError(
|
|
196
|
+
"BASELINE_WITHOUT_FAILURE_CONDITION",
|
|
197
|
+
`tooth ${spec.id} is baseline but states no failure condition (when should it go RED and what replaces it?)`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!spec.mustRed || spec.mustRed.length === 0) {
|
|
201
|
+
throw new ToothRegistrationError(
|
|
202
|
+
"NO_MUST_RED",
|
|
203
|
+
`tooth ${spec.id} declares no must-red mutations — a check that cannot fail is decoration`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
for (const mr of spec.mustRed) {
|
|
207
|
+
if (!mr.mutate.trim()) {
|
|
208
|
+
throw new ToothRegistrationError("MUST_RED_UNANSWERED", `tooth ${spec.id}: empty mutation`);
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
mr.caughtOnlyBy !== "this" &&
|
|
212
|
+
(!mr.caughtOnlyBy.alsoCaughtBy.trim() || !mr.caughtOnlyBy.whyStillNeeded.trim())
|
|
213
|
+
) {
|
|
214
|
+
throw new ToothRegistrationError(
|
|
215
|
+
"MUST_RED_UNANSWERED",
|
|
216
|
+
`tooth ${spec.id}: mutation ${JSON.stringify(mr.mutate)} must answer "who else would catch this?" — either "this" or a justified alsoCaughtBy`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const profile of spec.profiles) {
|
|
221
|
+
const allowed = PROFILE_LAYERS[profile];
|
|
222
|
+
for (const layer of spec.layers) {
|
|
223
|
+
if (!allowed.has(layer)) {
|
|
224
|
+
throw new ToothRegistrationError(
|
|
225
|
+
"TIER_BOUNDARY",
|
|
226
|
+
`tooth ${spec.id} is tagged profile "${profile}" but exercises layer ${layer}, outside that profile's set`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const site = callerSite();
|
|
232
|
+
registry.set(spec.id, site === undefined ? { ...spec } : { ...spec, registeredAt: site });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Teeth that run for a profile and a set of opted-in capabilities.
|
|
237
|
+
* Capabilities default to all, so a caller that does not care gets the
|
|
238
|
+
* broadest set rather than silently running less than it thinks.
|
|
239
|
+
*/
|
|
240
|
+
export function teethFor(
|
|
241
|
+
profile: Profile,
|
|
242
|
+
capabilities: readonly Capability[] = ALL_CAPABILITIES,
|
|
243
|
+
): ToothSpec[] {
|
|
244
|
+
return [...registry.values()].filter(
|
|
245
|
+
(t) =>
|
|
246
|
+
t.profiles.includes(profile) &&
|
|
247
|
+
(t.requiresCapability === undefined || capabilities.includes(t.requiresCapability)),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function allTeeth(): ToothSpec[] {
|
|
252
|
+
return [...registry.values()];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Export consumed by the mutation-runner: every tooth with its declared
|
|
257
|
+
* must-red list (the runner's contract: unmutated baseline must be 0-fail,
|
|
258
|
+
* each declared mutation must turn the owning tooth red).
|
|
259
|
+
*/
|
|
260
|
+
export function exportForMutationRunner(): Array<{ id: string; mustRed: MustRed[] }> {
|
|
261
|
+
return [...registry.values()].map((t) => ({ id: t.id, mustRed: t.mustRed }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Test-only: reset the registry between suites. */
|
|
265
|
+
export function clearRegistry(): void {
|
|
266
|
+
registry.clear();
|
|
267
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness self-check teeth — the tier lower-bound (a review fix:
|
|
3
|
+
* "零齿 = 假绿"). Each profile's tier must contain real (non-harness)
|
|
4
|
+
* teeth; a broken tier filter that empties a tier must go RED.
|
|
5
|
+
*
|
|
6
|
+
* Two layers of fail-closed protection:
|
|
7
|
+
* 1. runner.ts: a profile/adapter selection of ZERO checks is a typed
|
|
8
|
+
* HARNESS_EMPTY_SELECTION FAIL (an empty receipt is never green);
|
|
9
|
+
* 2. these teeth: a tier that keeps only the harness self-check teeth
|
|
10
|
+
* (non-empty receipt, but no real teeth) also goes RED.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { registerTooth, teethFor, type Profile } from "./registry.ts";
|
|
14
|
+
|
|
15
|
+
const HARNESS_PREFIX = "harness.";
|
|
16
|
+
|
|
17
|
+
/** A profile tier must contain at least one real (non-self-check) tooth. */
|
|
18
|
+
export async function checkTierHasTeeth(profile: Profile): Promise<void> {
|
|
19
|
+
const selected = teethFor(profile);
|
|
20
|
+
const real = selected.filter((t) => !t.id.startsWith(HARNESS_PREFIX));
|
|
21
|
+
assert.ok(
|
|
22
|
+
real.length > 0,
|
|
23
|
+
`HARNESS_EMPTY_TIER: profile ${profile} has ${selected.length} teeth, ${real.length} non-harness (tier filter likely emptied it)`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const profile of ["swap", "service"] as const) {
|
|
28
|
+
registerTooth({
|
|
29
|
+
id: `harness.teeth-present-${profile}`,
|
|
30
|
+
profiles: [profile],
|
|
31
|
+
layers: ["L0"],
|
|
32
|
+
kind: { kind: "invariant" },
|
|
33
|
+
mustRed: [
|
|
34
|
+
{
|
|
35
|
+
mutate: `the tier filter drops every non-harness tooth from the ${profile} tier`,
|
|
36
|
+
caughtOnlyBy: {
|
|
37
|
+
alsoCaughtBy: "harness.empty-selection fail-closed (only when zero teeth remain at all)",
|
|
38
|
+
whyStillNeeded:
|
|
39
|
+
"empty-selection catches total emptiness; this tooth catches a tier that keeps the self-check teeth but loses the real ones — a non-empty receipt that would still be a false green",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
run: () => checkTierHasTeeth(profile),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** DST teeth: seeded replay, safety/liveness under faults, and fault-surface coverage. */
|
|
2
|
+
import { registerTooth } from "./registry.ts";
|
|
3
|
+
import {
|
|
4
|
+
checkFaultSurfaceCovered,
|
|
5
|
+
checkSeedReplayIdentical,
|
|
6
|
+
checkSmokeSeeds,
|
|
7
|
+
} from "../sim/checks.ts";
|
|
8
|
+
|
|
9
|
+
registerTooth({
|
|
10
|
+
id: "sim.seed-replay-identical",
|
|
11
|
+
profiles: ["service"],
|
|
12
|
+
layers: ["L1", "L2"],
|
|
13
|
+
kind: { kind: "invariant" },
|
|
14
|
+
mustRed: [
|
|
15
|
+
{
|
|
16
|
+
mutate: "the scheduler reads ambient entropy or ignores the supplied seed",
|
|
17
|
+
caughtOnlyBy: "this",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
run: checkSeedReplayIdentical,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
registerTooth({
|
|
24
|
+
id: "sim.smoke-invariants",
|
|
25
|
+
profiles: ["service"],
|
|
26
|
+
layers: ["L1", "L2"],
|
|
27
|
+
kind: { kind: "invariant" },
|
|
28
|
+
mustRed: [
|
|
29
|
+
{
|
|
30
|
+
mutate: "journal fsync acknowledges an intent without making it durable",
|
|
31
|
+
caughtOnlyBy: "this",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
mutate: "the host starts experiment without stopping the stable incarnation",
|
|
35
|
+
caughtOnlyBy: {
|
|
36
|
+
alsoCaughtBy: "fake-host no-dual-run scripted tooth",
|
|
37
|
+
whyStillNeeded:
|
|
38
|
+
"the scripted tooth checks one named path; DST checks the property after every seeded effect and recovery interleaving",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
mutate: "terminal recovery leaves hosted work quiesced",
|
|
43
|
+
caughtOnlyBy: "this",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
run: checkSmokeSeeds,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
registerTooth({
|
|
50
|
+
id: "sim.fault-surface-covered",
|
|
51
|
+
profiles: ["service"],
|
|
52
|
+
layers: ["L1", "L2"],
|
|
53
|
+
kind: { kind: "invariant" },
|
|
54
|
+
mustRed: [
|
|
55
|
+
{
|
|
56
|
+
mutate: "the fixed corpus silently stops generating one declared fault class",
|
|
57
|
+
caughtOnlyBy: "this",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
run: checkFaultSurfaceCovered,
|
|
61
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@botiverse/k-carrier",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A fail-closed upgrade carrier for long-running managed services: two-slot upgrade transactions (promote/rollback), host-driven quiesce/resume handoff, and post-upgrade convergence read-back.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./core/src/index.ts",
|
|
12
|
+
"./harness": "./harness/src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"k-harness": "./harness/src/cli.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"core/src/**/*.ts",
|
|
19
|
+
"harness/src/**/*.ts",
|
|
20
|
+
"!**/*.test.ts",
|
|
21
|
+
"docs",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"NOTICE",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test --test-timeout=180000 \"core/src/**/*.test.ts\" \"harness/src/**/*.test.ts\"",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"lint": "oxlint --deny-warnings core harness examples",
|
|
33
|
+
"ratchet": "bash scripts/ratchets.sh",
|
|
34
|
+
"sim": "node harness/src/cli.ts sim",
|
|
35
|
+
"check": "pnpm typecheck && pnpm lint && pnpm ratchet && pnpm test"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^26.1.2",
|
|
39
|
+
"oxlint": "^1.0.0",
|
|
40
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
41
|
+
"typescript": "^5.6.0"
|
|
42
|
+
}
|
|
43
|
+
}
|