run402 4.69.3 → 4.69.5

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.
@@ -3507,6 +3507,7 @@ export class GitvaultVault {
3507
3507
  */
3508
3508
  async push(options) {
3509
3509
  let conflicts = 0;
3510
+ let removalRotated = false;
3510
3511
  let base = options.base ?? (await this.materialize());
3511
3512
  for (;;) {
3512
3513
  // gitvault-multi-writer (task 5.8) — checked fresh on EVERY attempt
@@ -3518,10 +3519,34 @@ export class GitvaultVault {
3518
3519
  // the ordinary GITVAULT_WRITER_NOT_ADMITTED on the first attempt.
3519
3520
  this.assertCallerIsWriter("publishing gitvault head", conflicts > 0);
3520
3521
  const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
3521
- const published = await this.publishGeneration({
3522
- base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
3523
- force_checkpoint: options.checkpoint === true, cutoff: options.cutoff ?? null, capture_binding: options.capture_binding,
3524
- });
3522
+ let published;
3523
+ try {
3524
+ published = await this.publishGeneration({
3525
+ base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
3526
+ force_checkpoint: options.checkpoint === true, cutoff: options.cutoff ?? null, capture_binding: options.capture_binding,
3527
+ });
3528
+ }
3529
+ catch (e) {
3530
+ // gitvault-human-envelopes D5 ("Remove: next capture rotates the
3531
+ // epoch key to the remaining set") / gitvault-multi-writer D6: an
3532
+ // outstanding MEMBERSHIP REMOVAL is completed by the next push of any
3533
+ // surviving writer — rotate under the writer-capable
3534
+ // reason:"member_removed", re-materialize on the rotated chain, and
3535
+ // retry this push once. Every OTHER cause the gate names (a
3536
+ // migration bootstrap, an epoch-secret exposure) stays THROWN, with
3537
+ // the enriched next_actions naming its owner + step-up remedy.
3538
+ const details = isRun402Error(e) && e.code === "EPOCH_ROTATION_REQUIRED"
3539
+ ? (e.details ?? {})
3540
+ : null;
3541
+ const removalOnly = details !== null && (details.revocation_outstanding === true || details.writer_removal_outstanding === true)
3542
+ && details.migration_required !== true && details.exposure_outstanding !== true;
3543
+ if (!removalOnly || removalRotated)
3544
+ throw e;
3545
+ removalRotated = true;
3546
+ await this.rotateEpochForMemberRemoval();
3547
+ base = await this.materialize();
3548
+ continue;
3549
+ }
3525
3550
  if (published.outcome === "conflict") {
3526
3551
  conflicts += 1;
3527
3552
  if (conflicts > this.retries)
@@ -3960,6 +3985,20 @@ export class GitvaultVault {
3960
3985
  fail("GITVAULT_DESIRED_STATE_UNAVAILABLE", "the gateway did not report desired-recipient state (desired[]) for this vault — an epoch rotation cannot compute its H-partition without it", "computing the epoch-rotation H-partition");
3961
3986
  }
3962
3987
  const pinManifest = await this.loadEffectivePinManifest(base.generation);
3988
+ // The vault record: `writer_set` (the chain's active writers, by
3989
+ // principal) and `ineligible_members` (the gateway-blocked writers this
3990
+ // rotation's writer_set_update must remove). Read ONCE here; both the
3991
+ // H-partition below and the writer_set_update fold-in further down use it.
3992
+ const vaultRecord = await this.transport.getVaultRecord({ repo_id: this.repoId });
3993
+ const ineligible = vaultRecord.ineligible_members ?? [];
3994
+ // kygit-handoff's member-removal decision: a keyed desired recipient
3995
+ // whose signing key is an active writer AND survives this rotation is
3996
+ // included on its current directory fingerprint, pin or no pin (the
3997
+ // writer set is client-signed chain state — the same authority that
3998
+ // lets it sign heads). Mirrors the gateway's own rule under its fence.
3999
+ const blockedWriterPrincipalIds = new Set(ineligible.map((m) => m.principal_id));
4000
+ const survivingWriterPrincipalIds = new Set((vaultRecord.writer_set?.writers ?? []).map((w) => w.principal_id).filter((id) => !blockedWriterPrincipalIds.has(id)));
4001
+ const directoryFingerprintOf = new Map(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => [d.principal_id, d.ek_fingerprint]));
3963
4002
  // D196's H-partition: included / excluded_keyless / excluded_unconfirmed.
3964
4003
  // A `pending_removal` desired-recipient row is EXCLUDED from H entirely
3965
4004
  // (the gateway's own H is `status='active'` only) — that omission is
@@ -3977,6 +4016,21 @@ export class GitvaultVault {
3977
4016
  }
3978
4017
  const pinned = pinManifest.pinnedFingerprintOf.get(d.principal_id);
3979
4018
  if (pinned === undefined) {
4019
+ if (survivingWriterPrincipalIds.has(d.principal_id)) {
4020
+ // A surviving writer without a pin: included on the directory key.
4021
+ let pub;
4022
+ try {
4023
+ pub = fromBase64url(d.public_key, "public_key");
4024
+ }
4025
+ catch {
4026
+ fail("VALIDATION_FAILED", `surviving writer ${d.principal_id}'s directory public key is not valid base64url`, "computing the epoch-rotation H-partition");
4027
+ }
4028
+ if (ekFingerprint(pub) !== d.ek_fingerprint) {
4029
+ fail("VALIDATION_FAILED", `surviving writer ${d.principal_id}'s directory public key does not hash to its declared fingerprint`, "computing the epoch-rotation H-partition");
4030
+ }
4031
+ included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
4032
+ continue;
4033
+ }
3980
4034
  excludedUnconfirmed.push(d.principal_id);
3981
4035
  continue;
3982
4036
  }
@@ -4017,7 +4071,7 @@ export class GitvaultVault {
4017
4071
  // Self-consistency (defense in depth — the gateway's own recomputation under its live lock is authoritative).
4018
4072
  const desiredIds = new Set(desired.filter((d) => d.status === "active").map((d) => d.principal_id));
4019
4073
  const keyedIds = new Set(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => d.principal_id));
4020
- const hCheck = checkHPartition({ desiredPrincipalIds: desiredIds, keyedPrincipalIds: keyedIds, pinnedFingerprintOf: pinManifest.pinnedFingerprintOf, included, excludedKeylessPrincipalIds: excludedKeyless, excludedUnconfirmedPrincipalIds: excludedUnconfirmed });
4074
+ const hCheck = checkHPartition({ desiredPrincipalIds: desiredIds, keyedPrincipalIds: keyedIds, pinnedFingerprintOf: pinManifest.pinnedFingerprintOf, included, excludedKeylessPrincipalIds: excludedKeyless, excludedUnconfirmedPrincipalIds: excludedUnconfirmed, survivingWriterPrincipalIds, directoryFingerprintOf });
4021
4075
  if (!hCheck.ok)
4022
4076
  fail("VALIDATION_FAILED", `internal: the H-partition this producer built is not a valid bijection over H (${hCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the epoch-rotation H-partition");
4023
4077
  const targetPartitionDigest = computeTargetPartitionDigest({
@@ -4057,8 +4111,6 @@ export class GitvaultVault {
4057
4111
  // with zero client changes — the fold-in doesn't care WHY a key
4058
4112
  // is blocked, only THAT it is.
4059
4113
  const writerPin = this.repoFile().writer_set_pin;
4060
- const vaultRecord = await this.transport.getVaultRecord({ repo_id: this.repoId });
4061
- const ineligible = vaultRecord.ineligible_members ?? [];
4062
4114
  let writerSetUpdate = null;
4063
4115
  if (ineligible.length > 0) {
4064
4116
  if (!writerPin) {
@@ -4090,10 +4142,19 @@ export class GitvaultVault {
4090
4142
  }
4091
4143
  // D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
4092
4144
  const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
4145
+ // D204 (rev 43): `migration_bootstrap` is schema-optional but the
4146
+ // gateway's fence requires the boolean on BOTH the descriptor and the
4147
+ // payload, and requires the two to agree with its own locked branch
4148
+ // value (the vault's migration flag AND no predecessor manifest). This
4149
+ // producer never drives the bootstrap branch — that is the pre-rev-42
4150
+ // repair ceremony, which co-rides a first manifest — so it declares
4151
+ // `false`; omitting the field is refused RECIPIENT_SET_MISMATCH.
4152
+ const migrationBootstrap = false;
4093
4153
  const descriptorFields = {
4094
4154
  format: GITVAULT_FORMAT, object_kind: "rotation_attempt_descriptor", suite: GITVAULT_SUITE,
4095
4155
  repo_id: this.repoId, base_head_sha256: base.head_sha256, new_epoch: newEpoch,
4096
4156
  recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
4157
+ migration_bootstrap: migrationBootstrap,
4097
4158
  pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
4098
4159
  client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
4099
4160
  // D227 (rev 47): present together IFF this rotation carries a writer_set_update — frozen at THIS attempt's own admission fence, mirroring recipient_state_version/recipient_revocation_version's existing discipline.
@@ -4122,6 +4183,7 @@ export class GitvaultVault {
4122
4183
  const payloadBase = {
4123
4184
  new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
4124
4185
  recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
4186
+ migration_bootstrap: migrationBootstrap,
4125
4187
  pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
4126
4188
  epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
4127
4189
  recipient_authority_attestation: null, envelopes: sealedReceipts,
@@ -4266,6 +4328,27 @@ export class GitvaultVault {
4266
4328
  };
4267
4329
  }
4268
4330
  }
4331
+ /**
4332
+ * The writer-capable rotation that completes an org membership removal
4333
+ * (gitvault-multi-writer D6: "`member_removed` keeps its automatic
4334
+ * writer-capable path"). The removal itself already advanced the org's
4335
+ * D194 counters and flipped the member to `pending_removal`, so there is
4336
+ * nothing to declare: read the counters off the envelope-recipients read
4337
+ * (the same read the H-partition uses) and rotate under
4338
+ * `reason:"member_removed"`, which needs `gitvault.writer` only — any
4339
+ * surviving writer can run it, no owner step-up. Refuses
4340
+ * `GITVAULT_ROTATION_COUNTERS_UNAVAILABLE` on a gateway that does not yet
4341
+ * carry the counters on that read.
4342
+ */
4343
+ async rotateEpochForMemberRemoval(options = {}) {
4344
+ const recipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
4345
+ const state = recipients.recipient_state_version;
4346
+ const revocation = recipients.recipient_revocation_version;
4347
+ if (typeof state !== "string" || typeof revocation !== "string") {
4348
+ fail("GITVAULT_ROTATION_COUNTERS_UNAVAILABLE", "the gateway did not report the org's rotation counters on the envelope-recipients read — a member-removal rotation cannot be fenced without them; an owner can run `run402 repos access revoke-key <principal_id>` instead", "reading the rotation counters");
4349
+ }
4350
+ return this.rotateEpoch({ reason: "member_removed", recipient_state_version: state, recipient_revocation_version: revocation, client_idempotency_key: options.client_idempotency_key });
4351
+ }
4269
4352
  /**
4270
4353
  * The ONE fully self-contained rotation entry point: declares
4271
4354
  * `reason:"recipient_key_revoked"` (owner + step-up) for `principal_id`,