@eir-labs/coltrane 0.10.0 → 0.11.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.
Files changed (138) hide show
  1. package/agents/bandleader.json +54 -0
  2. package/agents/bill.json +4 -2
  3. package/agents/change-verifier.json +2 -1
  4. package/agents/code-implementer.json +7 -4
  5. package/agents/john.json +5 -3
  6. package/agents/lineage-scout-external.json +19 -6
  7. package/agents/lineage-scribe.json +20 -9
  8. package/agents/lineage-weaver.json +26 -11
  9. package/agents/miles.json +2 -1
  10. package/agents/players/bandleader.md +70 -0
  11. package/agents/pr-publisher.json +4 -2
  12. package/agents/red-spec-drafter.json +12 -6
  13. package/agents/room-prober.json +29 -0
  14. package/agents/source-walker.json +1 -1
  15. package/charts/room-probe-chart-v1.json +14 -0
  16. package/charts/worker-contract-docker-v2.json +135 -0
  17. package/charts/worker-contract-v3.json +206 -0
  18. package/dist/src/boundary_check.d.ts +18 -0
  19. package/dist/src/boundary_check.js +70 -0
  20. package/dist/src/boundary_check.js.map +1 -0
  21. package/dist/src/boundary_scan.d.ts +62 -0
  22. package/dist/src/boundary_scan.js +93 -0
  23. package/dist/src/boundary_scan.js.map +1 -0
  24. package/dist/src/chart.js +34 -1
  25. package/dist/src/chart.js.map +1 -1
  26. package/dist/src/claude_invoker.d.ts +2 -1
  27. package/dist/src/claude_invoker.js +150 -17
  28. package/dist/src/claude_invoker.js.map +1 -1
  29. package/dist/src/cli.d.ts +1 -1
  30. package/dist/src/cli.js +61 -26
  31. package/dist/src/cli.js.map +1 -1
  32. package/dist/src/drain_preflight.d.ts +33 -0
  33. package/dist/src/drain_preflight.js +97 -0
  34. package/dist/src/drain_preflight.js.map +1 -0
  35. package/dist/src/genome_schema.d.ts +390 -9
  36. package/dist/src/genome_schema.js +163 -8
  37. package/dist/src/genome_schema.js.map +1 -1
  38. package/dist/src/genome_store.js +9 -0
  39. package/dist/src/genome_store.js.map +1 -1
  40. package/dist/src/genome_writer.d.ts +1 -1
  41. package/dist/src/genome_writer.js +10 -2
  42. package/dist/src/genome_writer.js.map +1 -1
  43. package/dist/src/gig_conformance.d.ts +66 -0
  44. package/dist/src/gig_conformance.js +153 -0
  45. package/dist/src/gig_conformance.js.map +1 -0
  46. package/dist/src/gig_tracker.js +8 -0
  47. package/dist/src/gig_tracker.js.map +1 -1
  48. package/dist/src/grounding.d.ts +44 -0
  49. package/dist/src/grounding.js +127 -0
  50. package/dist/src/grounding.js.map +1 -0
  51. package/dist/src/index.d.ts +1 -0
  52. package/dist/src/index.js +1 -0
  53. package/dist/src/index.js.map +1 -1
  54. package/dist/src/institution_loader.d.ts +29 -5
  55. package/dist/src/institution_loader.js +67 -6
  56. package/dist/src/institution_loader.js.map +1 -1
  57. package/dist/src/lineage_adoption.d.ts +82 -0
  58. package/dist/src/lineage_adoption.js +82 -0
  59. package/dist/src/lineage_adoption.js.map +1 -0
  60. package/dist/src/lineage_persist.d.ts +34 -0
  61. package/dist/src/lineage_persist.js +60 -0
  62. package/dist/src/lineage_persist.js.map +1 -0
  63. package/dist/src/loader.d.ts +13 -0
  64. package/dist/src/loader.js +43 -2
  65. package/dist/src/loader.js.map +1 -1
  66. package/dist/src/mcp.js +37 -4
  67. package/dist/src/mcp.js.map +1 -1
  68. package/dist/src/org_hire.d.ts +23 -0
  69. package/dist/src/org_hire.js +42 -0
  70. package/dist/src/org_hire.js.map +1 -0
  71. package/dist/src/outputs.d.ts +17 -0
  72. package/dist/src/outputs.js +86 -1
  73. package/dist/src/outputs.js.map +1 -1
  74. package/dist/src/player_to_claude_code.js +26 -2
  75. package/dist/src/player_to_claude_code.js.map +1 -1
  76. package/dist/src/registry.d.ts +3 -1
  77. package/dist/src/registry.js +43 -3
  78. package/dist/src/registry.js.map +1 -1
  79. package/dist/src/repo_index.d.ts +58 -0
  80. package/dist/src/repo_index.js +232 -0
  81. package/dist/src/repo_index.js.map +1 -0
  82. package/dist/src/reuse.d.ts +44 -4
  83. package/dist/src/reuse.js +0 -0
  84. package/dist/src/reuse.js.map +1 -1
  85. package/dist/src/runtime.d.ts +82 -0
  86. package/dist/src/runtime.js +254 -25
  87. package/dist/src/runtime.js.map +1 -1
  88. package/dist/src/server.d.ts +37 -0
  89. package/dist/src/server.js +422 -11
  90. package/dist/src/server.js.map +1 -1
  91. package/dist/src/server_relay.d.ts +40 -2
  92. package/dist/src/server_relay.js +186 -19
  93. package/dist/src/server_relay.js.map +1 -1
  94. package/dist/src/tool_providers.d.ts +27 -0
  95. package/dist/src/tool_providers.js +48 -0
  96. package/dist/src/tool_providers.js.map +1 -1
  97. package/dist/src/venue_credential.d.ts +41 -0
  98. package/dist/src/venue_credential.js +57 -0
  99. package/dist/src/venue_credential.js.map +1 -0
  100. package/dist/src/venue_realize.d.ts +23 -0
  101. package/dist/src/venue_realize.js +43 -3
  102. package/dist/src/venue_realize.js.map +1 -1
  103. package/dist/src/venue_realizer.d.ts +224 -0
  104. package/dist/src/venue_realizer.js +889 -0
  105. package/dist/src/venue_realizer.js.map +1 -0
  106. package/dist/src/version.d.ts +1 -1
  107. package/dist/src/version.js +1 -1
  108. package/dist/src/worker.d.ts +37 -0
  109. package/dist/src/worker.js +127 -24
  110. package/dist/src/worker.js.map +1 -1
  111. package/dist/src/worker_env.d.ts +79 -0
  112. package/dist/src/worker_env.js +322 -0
  113. package/dist/src/worker_env.js.map +1 -0
  114. package/dist/src/workspace.d.ts +12 -1
  115. package/dist/src/workspace.js +10 -3
  116. package/dist/src/workspace.js.map +1 -1
  117. package/domain_types/change-context.json +59 -13
  118. package/domain_types/lineage-adoption-target.json +22 -0
  119. package/domain_types/lineage-map.json +7 -2
  120. package/domain_types/lineage-record.json +55 -5
  121. package/domain_types/prior-art-hit.json +16 -1
  122. package/domain_types/red-spec.json +5 -3
  123. package/domain_types/woodshed-record.json +149 -0
  124. package/evals/gist-present.json +7 -0
  125. package/institutions/coltrane.json +178 -6
  126. package/package.json +6 -3
  127. package/skills/non-vacuity-probe/fixtures/fixture-001.json +17 -0
  128. package/skills/non-vacuity-probe/meta.json +12 -0
  129. package/skills/non-vacuity-probe/skill.md +27 -0
  130. package/skills/non-vacuity-probe/skill.mjs +81 -0
  131. package/standards/lineage-adopt-v0.json +37 -0
  132. package/standards/room-probe-v1.json +28 -0
  133. package/standards/software-change-pr-v1.json +15 -15
  134. package/standards/software-change-red-first-v0.json +183 -0
  135. package/standards/spec-review-and-sequence-v0.json +112 -0
  136. package/standards/studio-session-v0.json +101 -0
  137. package/tours/coltrane.json +72 -0
  138. package/venues/engine-room-v1.json +32 -0
@@ -3,6 +3,7 @@
3
3
  // writes each typed output to the store (validated), links provenance (derived_from),
4
4
  // and records one ledger entry with a deterministic genome_hash + a run_fingerprint
5
5
  // that carries model_version + (empty, v0) eval_scores — honestly un-tempered.
6
+ import { lineageAdoption } from "./lineage_adoption.js";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import { PRIMITIVE_OUTPUT_TYPE, CORE_TYPES } from "./core_types.js";
8
9
  import { executeSkillAsync } from "./skill_subprocess.js";
@@ -21,6 +22,7 @@ import { resolveAndRealize } from "./venue_realize.js";
21
22
  export const CORE_TO_PRIMITIVE = Object.fromEntries(Object.entries(PRIMITIVE_OUTPUT_TYPE).map(([prim, core]) => [String(core), prim]));
22
23
  import { sha256Hex, canonJson, canonStructuralJson, runFingerprint, outputContentHash, CANONICAL_FORM_VERSION } from "./canonical_form.js";
23
24
  import { producersSha, reuseCacheKey, checkReuseEntry, runIdentityMismatch, checkpointRoleKey, CHECKPOINT_SCHEMA_VERSION, REUSE_SCHEMA_VERSION, } from "./reuse.js";
25
+ import { checkGigConformance } from "./gig_conformance.js";
24
26
  import { drainGigHeader } from "./output_mirror.js";
25
27
  import { LEDGER_SCHEMA_VERSION } from "./ledger.js";
26
28
  import { COLTRANE_VERSION } from "./version.js";
@@ -289,11 +291,24 @@ export async function runGig(standard, gigInput, deps) {
289
291
  // construction, and torn down at each chair's lifecycle end.
290
292
  let gigRealization;
291
293
  let gigVenue;
294
+ // The SUBSTRATE half of the wire. The policy realization above CONFINES a venue-named gig — it
295
+ // intersects the tool ceiling and refuses a breach — but stands up no room. Without this the
296
+ // container substrate (src/venue_realizer.ts) was unreachable from dispatch: a venue-named gig got
297
+ // NO ROOM, only paper confinement. When the resolved venue declares mcp_servers AND a realizer is
298
+ // wired, the substrate is realized here, ONCE, before any chair — and torn down beside the policy
299
+ // layer in the finally block below.
300
+ let gigSubstrate;
292
301
  if (deps.venue !== undefined) {
293
302
  const realization = resolveAndRealize(deps.venue, {
294
303
  venues: new Map(deps.venues ?? []),
295
304
  seats: standard.agents.map((agent) => ({ agent })),
296
- ambientEnv: {},
305
+ // The ambient container environment realize() filters to each seat's allowlisted env
306
+ // (SEAT_ENV_ALLOWLIST — PATH/HOME). Passing `{}` here was the other half of the ENOENT
307
+ // measured on gig 87cffa2c: even a correct allowlist has nothing to admit when the caller
308
+ // feeds it an empty env, so every venue-confined seat spawned with no PATH and died. The
309
+ // allowlist is what makes handing realize() the whole process.env safe — it carries PATH/HOME
310
+ // through and drops every credential (COLTRANE_DRAIN_KEY et al.), never inheriting wholesale.
311
+ ambientEnv: process.env,
297
312
  ...(deps.credentialsPresent ? { credentialsPresent: deps.credentialsPresent } : {}),
298
313
  gigId: gig_id,
299
314
  });
@@ -303,6 +318,33 @@ export async function runGig(standard, gigInput, deps) {
303
318
  gigRealization = realization;
304
319
  // realize() only returns ok once the slug resolved, so the venue is present in the map.
305
320
  gigVenue = deps.venues?.get(deps.venue);
321
+ // Realize the SUBSTRATE when the venue declares servers to stand up OR the RUN names a repository
322
+ // to populate the room's tree with — either is a reason to build a real room. A venue with NEITHER
323
+ // has nothing to stand up (the empty room stays free), and an absent realizer is the opt-out that
324
+ // keeps every pre-wire caller byte-identical. realize() tolerates an empty mcp_servers list (its
325
+ // credential/compose steps iterate the servers as no-ops — the existing repo_url-declines law
326
+ // exercises exactly this, mcp_servers: [], and gets a handle back), so a repository-only run
327
+ // reaches it without an empty services list becoming fatal. credentialResolver defaults to an
328
+ // empty async resolver — realize() takes it as a required positional, so undefined would throw at
329
+ // credential-resolution time; the empty default satisfies the interface without a new mandatory
330
+ // field. A bring-up failure propagates like a policy refusal: no chair spawns.
331
+ //
332
+ // The repository is `deps.repoUrl` — named by the RUN, explicit at dispatch. The per-gig git
333
+ // credential plumbing (drainKey/instance/endpoint) is READ FROM THE AMBIENT ENVIRONMENT here, the
334
+ // same discipline src/workspace.ts documents: the SUBJECT of work must be explicit, but the
335
+ // credential that clones it is ambient plumbing, never a per-repository fact. Absent env → the
336
+ // populate declines on its own terms (prepareWorkspace names what it could not obtain).
337
+ if (deps.venueRealizer && gigVenue && (gigVenue.mcp_servers.length > 0 || deps.repoUrl)) {
338
+ gigSubstrate = await deps.venueRealizer.realize(gigVenue, deps.credentialResolver ?? (async () => ({})), {
339
+ gigId: gig_id,
340
+ ...(deps.repoUrl ? { repoUrl: deps.repoUrl } : {}),
341
+ ...(process.env["COLTRANE_DRAIN_KEY"] ? { drainKey: process.env["COLTRANE_DRAIN_KEY"] } : {}),
342
+ ...(process.env["COLTRANE_INSTANCE"] ? { instance: process.env["COLTRANE_INSTANCE"] } : {}),
343
+ ...(process.env["COLTRANE_GIT_CREDENTIALS_URL"]
344
+ ? { gitCredentialsEndpoint: process.env["COLTRANE_GIT_CREDENTIALS_URL"] }
345
+ : {}),
346
+ });
347
+ }
306
348
  }
307
349
  // Hash the gig input LAZILY. Three callers want it now (#196's provenance backfill, the
308
350
  // resume identity, and the reuse key) but a hostile or circular payload must not be
@@ -702,23 +744,94 @@ export async function runGig(standard, gigInput, deps) {
702
744
  // THE GATE. A resume into a moved genome would have chairs from genome B consuming sealed
703
745
  // outputs from genome A, and nothing in input_shas / genome_hash / run_fingerprint would
704
746
  // record that it happened — the manifest would describe a system that never existed.
705
- const drift = runIdentityMismatch(cp.identity, identity());
747
+ //
748
+ // SCOPED RELAXATION (gig ce902971): producers_sha — the fold of every producing agent's
749
+ // definition — is NOT a resume gate when every chair still to run is human. Those producers
750
+ // already produced; their sealed, hashed outputs cannot change under a definition edit, and a
751
+ // producing agent's definition cannot change what a PERSON is being asked to decide. Without
752
+ // this, a parked gig becomes permanently unapprovable the moment any agent definition moves
753
+ // after it parks. This is the ONLY relaxation and its ONLY justification is 'this input cannot
754
+ // affect what remains to run': every other identity field still gates, and producers_sha
755
+ // itself still gates the moment ANY remaining chair is a model chair. The remaining-chair set
756
+ // is derived synchronously from data already in scope (cp.roles, standard.phases), so the gate
757
+ // still fires before the first await.
758
+ //
759
+ // The predicate is two-valued — a chair is human or it is a model chair. If a future
760
+ // non-human, agent-less chair type is introduced it MUST be re-examined here: it must not
761
+ // silently inherit this waiver.
762
+ const sealedRoles = new Set(cp.roles.map((r) => r.role));
763
+ const remainingChairs = standard.phases.flatMap((p) => p.chairs).filter((c) => !sealedRoles.has(c.role));
764
+ const allRemainingHuman = remainingChairs.length > 0 &&
765
+ remainingChairs.every((c) => c.human === true && (c.agent_slug ?? "") === "");
766
+ // #19 — an OMITTED --depth is not a DISAGREEMENT. When the operator states no depth,
767
+ // `deps.depth` is undefined and identity() resolves it to "" — collapsing "I did not say"
768
+ // and "I said something different" into one value that then reads as a mismatch against a
769
+ // checkpoint written with a stated depth. The checkpoint already records its depth (a
770
+ // first-class run-identity field), so an unstated depth INHERITS it: substitute the
771
+ // checkpoint's depth as the effective comparison value ONLY when deps.depth is undefined.
772
+ // An EXPLICIT depth still compares as-is, so a skim resume of a deep checkpoint still
773
+ // refuses (#237: depth shapes what the model is asked for; a skim half stitched to a deep
774
+ // half is a real defect). This never MANUFACTURES a false mismatch and it is NOT a waiver
775
+ // of a stated one — see the note beside the 'never waived' rule in src/reuse.ts. identity()
776
+ // stays a pure function of deps, so `cur = identity()` below and every non-comparison use
777
+ // still see the operator's actual (undefined→"") depth.
778
+ // #20 — an OMITTED --input is no more a DISAGREEMENT than an omitted --depth, and the same
779
+ // omission-vs-conflict distinction applies. A checkpoint stores ONLY identity.gig_input_sha —
780
+ // a hash of the canonical dispatch payload — and NEVER the payload itself (see src/reuse.ts,
781
+ // RunIdentity.gig_input_sha), so the omitted payload cannot be "recovered from the checkpoint";
782
+ // it is INHERITED. The producers already consumed the input and sealed their hashed outputs, so
783
+ // when every remaining chair is human — approving already-sealed work it does not read — an
784
+ // unstated --input inherits the checkpoint's recorded gig_input_sha. A SUPPLIED value is never
785
+ // substituted (deps.gig_input_omitted is false), so a disagreeing --input still drives a
786
+ // refusal; and while any remaining chair is a MODEL chair (allRemainingHuman false) the payload
787
+ // is exactly what that chair will consume, so gig_input_sha keeps gating unchanged. identity()
788
+ // itself stays untouched, so `cur = identity()` below and every checkpoint write still see the
789
+ // operator's actual (omitted→{}) payload hash — the substitution is only the comparison value.
790
+ const current = identity();
791
+ const inheritDepth = deps.depth === undefined;
792
+ const inheritGigInput = allRemainingHuman && deps.gig_input_omitted === true;
793
+ const currentForGate = {
794
+ ...current,
795
+ ...(inheritDepth ? { depth: cp.identity.depth } : {}),
796
+ ...(inheritGigInput ? { gig_input_sha: cp.identity.gig_input_sha } : {}),
797
+ };
798
+ const drift = runIdentityMismatch(cp.identity, currentForGate, { waiveProducers: allRemainingHuman });
706
799
  if (drift.length > 0) {
707
800
  // DIAGNOSTIC HONESTY, not a widened resume. The genome genuinely moved, so the refusal
708
- // stands — but the operator's real fix is "resume from the build that wrote this", and
709
- // nothing told them which build that was. `engine_version` (stamped by every build since
710
- // this landed) names it; a checkpoint from before this field says so plainly rather than
711
- // crash. Lead with the version + the action; the raw before/after hashes still ride in
801
+ // stands — but the refusal must send the operator to the thing that ACTUALLY changed, not
802
+ // to a guess. When the producing and current engine versions DIFFER, "resume from the build
803
+ // that wrote this" is that thing, and `engine_version` names it. When they are IDENTICAL the
804
+ // old prose ("resume from a <version> build") named a version that already matched sending
805
+ // the reader to verify a correct build before they reached the drift bracket. So when the
806
+ // versions agree, name the fields that drifted instead, drawn from the SAME `drift` list that
807
+ // refused (never a second, re-derived one). The raw before/after hashes always ride in
712
808
  // `drift` for a builder who wants them.
713
809
  const cur = identity();
714
- const wroteBy = cp.engine_version
715
- ? `coltrane ${cp.engine_version}`
716
- : "an earlier build (engine version unrecorded)";
717
- const resumeAction = cp.engine_version
718
- ? `Resume from a ${cp.engine_version} build, or re-dispatch cold`
719
- : "Resume from the matching build, or re-dispatch cold";
720
- throw new ResumeRefused(gig_id, `this checkpoint was written by ${wroteBy} (genome_hash ${cp.identity.genome_hash}); ` +
721
- `the current build is coltrane ${COLTRANE_VERSION} (genome_hash ${cur.genome_hash}). ${resumeAction}`, drift);
810
+ // Each drift entry reads `<field>: checkpoint="..." current="..."` — the field is the token
811
+ // before the first ':'. This is the authoritative list `runIdentityMismatch` already returned;
812
+ // it is formatted here, never recomputed, so prose and refusal answer to one computation.
813
+ const driftedFields = drift.map((d) => d.slice(0, d.indexOf(":")));
814
+ let why;
815
+ if (cp.engine_version === COLTRANE_VERSION) {
816
+ why =
817
+ `this checkpoint and the current build are both coltrane ${COLTRANE_VERSION}, but the run ` +
818
+ `identity moved under that same version: ${driftedFields.join(", ")} changed since the ` +
819
+ `checkpoint was written (raw before/after values below). Re-dispatch cold, or restore the ` +
820
+ `prior ${driftedFields.join("/")} to resume`;
821
+ }
822
+ else if (cp.engine_version) {
823
+ why =
824
+ `this checkpoint was written by coltrane ${cp.engine_version} (genome_hash ${cp.identity.genome_hash}); ` +
825
+ `the current build is coltrane ${COLTRANE_VERSION} (genome_hash ${cur.genome_hash}). ` +
826
+ `Resume from a ${cp.engine_version} build, or re-dispatch cold`;
827
+ }
828
+ else {
829
+ why =
830
+ `this checkpoint was written by an earlier build (engine version unrecorded) ` +
831
+ `(genome_hash ${cp.identity.genome_hash}); the current build is coltrane ${COLTRANE_VERSION} ` +
832
+ `(genome_hash ${cur.genome_hash}). Resume from the matching build, or re-dispatch cold`;
833
+ }
834
+ throw new ResumeRefused(gig_id, why, drift);
722
835
  }
723
836
  const rolesInStandard = new Set(standard.phases.flatMap((p) => p.chairs.map((c) => c.role)));
724
837
  for (const r of cp.roles) {
@@ -945,6 +1058,48 @@ export async function runGig(standard, gigInput, deps) {
945
1058
  type: "chair_complete", phase: phase.name, role: hc.role, producer: deps.approved_by ?? "human",
946
1059
  output_types: [domain_type], duration_ms: Date.now() - t0,
947
1060
  });
1061
+ // A sealed lineage-verdict either grounds an institution or does not. Decide it here,
1062
+ // where the verdict and the record it approved are both in hand, and report the answer
1063
+ // either way. The record the verdict targets IS the input it approved — the same set
1064
+ // already sealed into this output's input_shas — so no lookup is invented.
1065
+ if (domain_type === "lineage-verdict") {
1066
+ const target = approvalInputs.find((i) => i.domain_type === "lineage-record") ?? approvalInputs[0];
1067
+ // An ENTRY human chair (depends_on []) has no upstream outputs — the record it approves
1068
+ // arrived in the dispatch payload. lineage-adopt-v0 is exactly that shape, and exists to
1069
+ // be: a record composed by any standard can be brought to a seat without re-running the
1070
+ // work that made it. Reading only from approvalInputs made the adoption blind to every
1071
+ // record seeded that way, which is every record that standard will ever see.
1072
+ //
1073
+ // Found by running it, not by testing it: five integration tests passed because all of
1074
+ // them seated the human chair downstream of a compose phase. They encoded the assumption
1075
+ // instead of testing it.
1076
+ //
1077
+ // The payload record carries no content_sha — it has not been sealed by THIS gig — so the
1078
+ // reference falls back to its id. LineageRecordRefSchema admits exactly this: record_ref
1079
+ // is "content_sha (OR SLUG) of the sealed lineage-record". Prefer the sha when a real
1080
+ // upstream output exists; use the slug when the record came in from outside.
1081
+ const seeded = gigInput["lineage-record"];
1082
+ const seededId = typeof seeded?.["id"] === "string" ? seeded["id"] : "";
1083
+ // WHICH institution this grounds. Carried by lineage-adoption-target in the payload,
1084
+ // because the attachment is a property of the adoption ACT — one record, approved once,
1085
+ // may be adopted into two institutions by two separate acts. Neither the record nor the
1086
+ // verdict holds it, and both promise the attachment in prose.
1087
+ const tgt = gigInput["lineage-adoption-target"];
1088
+ const institution_slug = typeof tgt?.["institution_slug"] === "string" ? tgt["institution_slug"] : "";
1089
+ const decision = lineageAdoption({
1090
+ verdict: (approval ?? null),
1091
+ record_ref: target?.content_sha ?? seededId,
1092
+ sealed_at: rec.created_at,
1093
+ institution_slug,
1094
+ });
1095
+ emit({
1096
+ type: "lineage_adoption", phase: phase.name, role: hc.role,
1097
+ adopt: decision.adopt,
1098
+ ...(decision.ref ? { record_ref: decision.ref.record_ref, approved_by: decision.ref.approved_by ?? "" } : {}),
1099
+ ...(decision.institution_slug ? { institution_slug: decision.institution_slug } : {}),
1100
+ ...(decision.refusals.length ? { refusals: decision.refusals.map((r) => r.reason) } : {}),
1101
+ });
1102
+ }
948
1103
  }
949
1104
  ready = ready.filter((c) => !(c.human === true && (c.agent_slug ?? "") === ""));
950
1105
  if (ready.length === 0)
@@ -1175,7 +1330,13 @@ export async function runGig(standard, gigInput, deps) {
1175
1330
  const sha = outputContentHash({
1176
1331
  core_type: spec.core_type,
1177
1332
  domain_type: o.domain_type,
1178
- domain_type_version: 1,
1333
+ // The version the loaded genome's type carries now, re-derived through the ONE owner
1334
+ // (typeVersionOf) rather than a constant — so the re-hash reproduces the pre-image the
1335
+ // original seal folded. NOTE (accepted point-in-time exposure, bill-change-plan step 5):
1336
+ // this is the CURRENT version; a cached output whose type was extended since it sealed
1337
+ // would re-hash under the newer version and be refused as stale. Point-in-time version
1338
+ // tracking is out of scope for this change.
1339
+ domain_type_version: deps.outputs.typeVersionOf(o.domain_type),
1179
1340
  domain: a.domain,
1180
1341
  primitive: spec.primitive,
1181
1342
  phase: a.phaseName,
@@ -1460,11 +1621,18 @@ export async function runGig(standard, gigInput, deps) {
1460
1621
  settleChairCost(p, false);
1461
1622
  throw e;
1462
1623
  }
1463
- finally {
1464
- // Venue dispatch wire: tear the room down at the chair lifecycle end. `teardown()` is
1465
- // idempotent and a no-op when no venue was named, so a venue-less gig is unaffected.
1466
- gigRealization?.teardown();
1467
- }
1624
+ // THE ROOM IS NOT TORN DOWN HERE. It used to be, in this chair-level `finally`, described as
1625
+ // "idempotent and a no-op when no venue was named". Idempotent yes; a no-op no
1626
+ // `src/venue_realize.ts` sets `torn = true`, and `canReach()` is `!torn && egress.includes(...)`.
1627
+ // So the FIRST chair to finish closed the room's egress probe for every chair after it, and a
1628
+ // multi-chair phase ran the rest of its seats against a room already reported torn down.
1629
+ //
1630
+ // It was invisible because nothing in the run path consults `canReach` today, and because the
1631
+ // policy realization's teardown costs nothing. Neither excuse survives a realizer that does real
1632
+ // work: a container torn down after chair one is gone for chair two.
1633
+ //
1634
+ // A room's lifetime is the GIG's, not a chair's. Teardown now runs once, at the gig boundary,
1635
+ // on both the success and failure paths — see the `finally` on runGig's outer try.
1468
1636
  }
1469
1637
  async function executeChair(p) {
1470
1638
  const { chair, phaseName, inputs, skills, output_specs, producer_slug, domain } = p;
@@ -1593,6 +1761,15 @@ export async function runGig(standard, gigInput, deps) {
1593
1761
  // venue resolved, so the invoker narrows the spawn by construction; both fields stay
1594
1762
  // absent otherwise (the venue-less path is unchanged).
1595
1763
  ...(gigRealization && gigVenue ? { realization: gigRealization, venue: gigVenue } : {}),
1764
+ // The substrate → spawn wire: when the room was actually stood up, hand the chair the
1765
+ // realized transports (docker-exec stdio configs) so the invoker points the spawn at the
1766
+ // servers INSIDE the room. Absent otherwise — the substrate-less path is unchanged.
1767
+ ...(gigSubstrate ? { substrateMcpConfigs: gigSubstrate.mcpServerConfigs } : {}),
1768
+ // The seat → spawn wire: when the substrate stood up a SEAT-BEARING room (a floor image
1769
+ // carrying the toolchain), hand the chair the room's workspace + container so the invoker
1770
+ // runs the seat INSIDE the room with the workspace as cwd. Absent when the room is the
1771
+ // production-only room image (no seat runs there) — the host-spawn path is unchanged.
1772
+ ...(gigSubstrate?.seat ? { seatExec: gigSubstrate.seat } : {}),
1596
1773
  onEvent: (ev) => {
1597
1774
  sink.fold(ev);
1598
1775
  emit({ type: "agent_event", phase: phaseName, role: chair.role, event: ev });
@@ -1765,13 +1942,21 @@ export async function runGig(standard, gigInput, deps) {
1765
1942
  const resolved = [];
1766
1943
  for (const spec of output_specs) {
1767
1944
  const keyed = data[spec.domain_type];
1768
- const slice = keyed !== undefined && keyed !== null ? keyed : single ? data : undefined;
1769
- if (slice === undefined || slice === null)
1945
+ const raw = keyed !== undefined && keyed !== null ? keyed : single ? data : undefined;
1946
+ if (raw === undefined || raw === null)
1770
1947
  continue;
1771
- if (typeof slice !== "object" || slice === null) {
1772
- throw new RuntimeError(`chair "${chair.role}" output "${spec.domain_type}" must be a JSON object, got ${typeof slice}`);
1948
+ // MULTI-RECORD SEAL. captureOutputWrites hands the runtime a LIST of records per declared type
1949
+ // a chair may seal MANY records of one type (gig 8baced9d's lineage scout made 15 accepted
1950
+ // output_write calls; the old last-wins collapse sealed 1). An ARRAY here is that list, and the
1951
+ // loop seals one record per element. A bare object is one record — the skill-backed path and
1952
+ // the single-output fallback still hand a plain object, so single-seal chairs are untouched.
1953
+ const slices = Array.isArray(raw) ? raw : [raw];
1954
+ for (const slice of slices) {
1955
+ if (typeof slice !== "object" || slice === null || Array.isArray(slice)) {
1956
+ throw new RuntimeError(`chair "${chair.role}" output "${spec.domain_type}" must be a JSON object, got ${Array.isArray(slice) ? "array" : slice === null ? "null" : typeof slice}`);
1957
+ }
1958
+ resolved.push({ spec, slice: slice });
1773
1959
  }
1774
- resolved.push({ spec, slice: slice });
1775
1960
  }
1776
1961
  // backfillShas refuses an ambiguous provenance field. Run it over EVERY slice up front so
1777
1962
  // that throw also lands before the first write, rather than midway through them.
@@ -1838,6 +2023,10 @@ export async function runGig(standard, gigInput, deps) {
1838
2023
  const rec = deps.outputs.write({
1839
2024
  core_type: spec.core_type,
1840
2025
  domain_type: spec.domain_type,
2026
+ // Stamp the REAL version the type carries at seal time, read through the ONE owner. Omitting
2027
+ // it let outputs.write default to 1, so a record sealed against a v3 type claimed v1 — inside
2028
+ // its content_sha pre-image, which is the record's identity.
2029
+ domain_type_version: deps.outputs.typeVersionOf(spec.domain_type),
1841
2030
  domain,
1842
2031
  gig_id,
1843
2032
  agent_slug: producer_slug,
@@ -2024,6 +2213,10 @@ export async function runGig(standard, gigInput, deps) {
2024
2213
  result.unresolved_evals = unresolved_evals;
2025
2214
  if (unfulfilledOutputs.length > 0)
2026
2215
  result.unfulfilled_outputs = unfulfilledOutputs;
2216
+ // The gig-close conformance classification — computed over the SETTLED produced[] (every amend
2217
+ // round resolved, every reuse/resume row folded in) so it grades the run as it finally stands.
2218
+ // Attached beside run_fingerprint, never folded into it and never sealed back into the gig.
2219
+ result.conformance = checkGigConformance(standard, produced, skipped, "complete", gig_id);
2027
2220
  // Say what was skipped and why. The ABSENCE of these fields is itself a claim — that every
2028
2221
  // chair in this manifest ran — so they are present only when there is something to report,
2029
2222
  // and `reuse` is present whenever the cache was wired even if nothing hit (a zero-hit run is
@@ -2075,6 +2268,42 @@ export async function runGig(standard, gigInput, deps) {
2075
2268
  });
2076
2269
  throw e;
2077
2270
  }
2271
+ finally {
2272
+ // ONE ROOM, ONE GIG, ONE TEARDOWN — on both paths. A room outlives every chair that sits in it
2273
+ // and dies with the run, which is what `lifecycle: ephemeral` means and what the chair-level
2274
+ // teardown this replaces could not express.
2275
+ //
2276
+ // AWAITED, because a realizer that stands up real resources returns a promise: the interface
2277
+ // already declares `Promise<void> | void`, and an unawaited teardown on the failure path means a
2278
+ // process can exit with a container still running. A leak on the path that is ALREADY going
2279
+ // badly is the worst place to have one.
2280
+ //
2281
+ // Best-effort by construction: a teardown that throws must not replace the gig's own outcome —
2282
+ // a failed teardown after a successful run would turn a good gig into a bad one, and after a
2283
+ // failed run would hide the real error behind a cleanup error. What it must never do is pass
2284
+ // silently, so it is reported on the drain-debug channel like every other best-effort seam here.
2285
+ try {
2286
+ await gigRealization?.teardown();
2287
+ }
2288
+ catch (te) {
2289
+ if (process.env["COLTRANE_DRAIN_DEBUG"]) {
2290
+ console.error(`[venue] teardown failed for gig ${gig_id}: ${te instanceof Error ? te.message : String(te)}`);
2291
+ }
2292
+ }
2293
+ // BOTH LAYERS ARE TORN DOWN, INDEPENDENTLY. The substrate handle (a real compose project, for the
2294
+ // container realizer) gets its OWN try/catch, NOT the policy teardown's: a shared one would let a
2295
+ // throwing substrate teardown skip the policy teardown (or vice versa), and a room that outlives
2296
+ // its gig is the leak this wire exists to close. Best-effort like the policy teardown — a throw
2297
+ // here must not replace the gig's outcome, only surface on the drain-debug channel.
2298
+ try {
2299
+ await gigSubstrate?.teardown();
2300
+ }
2301
+ catch (te) {
2302
+ if (process.env["COLTRANE_DRAIN_DEBUG"]) {
2303
+ console.error(`[venue] substrate teardown failed for gig ${gig_id}: ${te instanceof Error ? te.message : String(te)}`);
2304
+ }
2305
+ }
2306
+ }
2078
2307
  }
2079
2308
  // v0 eval-scorer: a minimal scan over the produced outputs. The named eval is
2080
2309
  // looked up by slug (no shared genome handle in the runtime today), so we use