@indigoai-us/hq-cloud 6.14.47 → 6.14.48
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/dist/cli/share.d.ts +11 -3
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +182 -19
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +530 -28
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/journal.d.ts +56 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +49 -1
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +37 -2
- package/dist/journal.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +598 -28
- package/src/cli/share.ts +197 -31
- package/src/cli/sync.ts +21 -11
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
package/dist/cli/share.d.ts
CHANGED
|
@@ -162,6 +162,7 @@ export declare const _testing: {
|
|
|
162
162
|
collectFiles: typeof collectFiles;
|
|
163
163
|
resolveNamedPath: typeof resolveNamedPath;
|
|
164
164
|
isWithinLexicalOrReal: typeof isWithinLexicalOrReal;
|
|
165
|
+
defaultConsoleLogger: typeof defaultConsoleLogger;
|
|
165
166
|
};
|
|
166
167
|
export interface ShareOptions {
|
|
167
168
|
/** Path(s) to share (files or directories) */
|
|
@@ -303,8 +304,10 @@ export interface ShareOptions {
|
|
|
303
304
|
* belong in THIS bucket regardless of local state" and uses the same
|
|
304
305
|
* DeleteObject + journal-removal path as `propagateDeletes`.
|
|
305
306
|
*
|
|
306
|
-
* Honors `propagateDeletePolicy
|
|
307
|
-
*
|
|
307
|
+
* Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
|
|
308
|
+
* own fallback when the caller passes nothing; every live caller passes
|
|
309
|
+
* `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
|
|
310
|
+
* journal entries with `direction === "up"` are decommissioned, so a
|
|
308
311
|
* misconfigured caller never erases content pulled from elsewhere.
|
|
309
312
|
*
|
|
310
313
|
* Independent of `propagateDeletes`: callers can opt into decommission
|
|
@@ -516,7 +519,7 @@ export interface ShareResult {
|
|
|
516
519
|
pathResults?: SharePathResult[];
|
|
517
520
|
aborted: boolean;
|
|
518
521
|
}
|
|
519
|
-
export type ShareDeleteRefusalReason = "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local" | "missing-delete-intent" | "intent-changed" | "transfer-error";
|
|
522
|
+
export type ShareDeleteRefusalReason = "stale-etag" | "legacy-no-etag" | "bulk-asymmetry" | "divergent-local" | "missing-delete-intent" | "intent-changed" | "recreated-locally" | "transfer-error";
|
|
520
523
|
export interface SharePathResult {
|
|
521
524
|
path: string;
|
|
522
525
|
status: "accepted" | "refused";
|
|
@@ -535,6 +538,11 @@ export declare function wrapFilterWithIgnoreVisibility(underlying: (absPath: str
|
|
|
535
538
|
* Share local file(s) to the entity vault.
|
|
536
539
|
*/
|
|
537
540
|
export declare function share(options: ShareOptions): Promise<ShareResult>;
|
|
541
|
+
/**
|
|
542
|
+
* Default human-readable share output. Preserves the exact format the CLI
|
|
543
|
+
* emitted before `onEvent` was added — tty users see no change.
|
|
544
|
+
*/
|
|
545
|
+
declare function defaultConsoleLogger(event: SyncProgressEvent): void;
|
|
538
546
|
/**
|
|
539
547
|
* One entry produced by collectFiles/walkDir. Files describe regular
|
|
540
548
|
* payloads that get hashed + size-checked + uploaded via uploadFile;
|
package/dist/cli/share.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../../src/cli/share.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAe,MAAM,aAAa,CAAC;AAelF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"share.d.ts","sourceRoot":"","sources":["../../src/cli/share.ts"],"names":[],"mappings":"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"}
|
package/dist/cli/share.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
|
12
12
|
import { localPathForVaultKey, vaultKeyForLocalPath } from "../local-path-codec.js";
|
|
13
13
|
import { uploadFile, uploadSymlink, toPosixKey, headRemoteFile, deleteRemoteFile, downloadFile, primeObjectTransport, primeUploads, } from "../s3.js";
|
|
14
14
|
import * as crypto from "crypto";
|
|
15
|
-
import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, normalizeEtag, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, } from "../journal.js";
|
|
15
|
+
import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, isTombstone, normalizeEtag, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, } from "../journal.js";
|
|
16
16
|
import { createIgnoreFilter, hasControlCharacters, isExpectedIgnore, isWithinSizeLimit, } from "../ignore.js";
|
|
17
17
|
import { wrapFilterWithPersonalVaultDefaults, } from "../personal-vault-exclusions.js";
|
|
18
18
|
import { isGeneratedCoreMirrorKey, isPersonalVaultDeletionExcluded, } from "../personal-vault.js";
|
|
@@ -235,6 +235,7 @@ export const _testing = {
|
|
|
235
235
|
collectFiles,
|
|
236
236
|
resolveNamedPath,
|
|
237
237
|
isWithinLexicalOrReal,
|
|
238
|
+
defaultConsoleLogger,
|
|
238
239
|
};
|
|
239
240
|
/**
|
|
240
241
|
* Pure Stage-1 pass for push: walk the candidate file list, hash each one,
|
|
@@ -489,7 +490,7 @@ export async function share(options) {
|
|
|
489
490
|
const REFUSED_STALE_PATH_CAP = 50;
|
|
490
491
|
async function createPushRunContext(options) {
|
|
491
492
|
const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
|
|
492
|
-
|
|
493
|
+
const propagateDeletePolicy = options.propagateDeletePolicy ?? "owned-only";
|
|
493
494
|
const baseEmit = options.onEvent ?? defaultConsoleLogger;
|
|
494
495
|
if (vaultConfig && entityContext) {
|
|
495
496
|
throw new Error("share() requires exactly one of `vaultConfig` or `entityContext`, not both. " +
|
|
@@ -507,9 +508,30 @@ async function createPushRunContext(options) {
|
|
|
507
508
|
const ctx = entityContext
|
|
508
509
|
? entityContext
|
|
509
510
|
: await resolveEntityContext(companyRef, vaultConfig);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
511
|
+
// NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
|
|
512
|
+
// `owned-only` -> `currency-gated` here (366721b). It was correct for the
|
|
513
|
+
// problem it was written against and is now actively harmful, for three
|
|
514
|
+
// reasons that only became true later:
|
|
515
|
+
//
|
|
516
|
+
// 1. Its ONLY reachable effect in production was to override an EXPLICIT
|
|
517
|
+
// `owned-only`. Every live caller passes a policy
|
|
518
|
+
// (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
|
|
519
|
+
// resolver already defaults to `currency-gated`. So the coercion never
|
|
520
|
+
// fired on a default run — it fired exactly when an operator had set
|
|
521
|
+
// `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
|
|
522
|
+
// 2. Once delete authorization moved to ETag currency (no watcher-minted
|
|
523
|
+
// intent required), that override stopped being cosmetic: an operator
|
|
524
|
+
// reaching for the rollback because deletes were misbehaving would keep
|
|
525
|
+
// getting intent-less etag-only deletes on their personal vault. A
|
|
526
|
+
// rollback knob that silently exempts a vault is not a rollback knob.
|
|
527
|
+
// 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
|
|
528
|
+
// is fixed twice over by mechanisms that postdate it: the vault-litter
|
|
529
|
+
// drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
|
|
530
|
+
// and etag-gated deletes propagate an intent-less `direction:"down"`
|
|
531
|
+
// removal under the default policy without needing any coercion.
|
|
532
|
+
//
|
|
533
|
+
// So `owned-only` now means owned-only on every vault kind. It is the strict
|
|
534
|
+
// rollback path and must stay strictly stricter than the default everywhere.
|
|
513
535
|
// Mirror push progress into the shared cross-process snapshot (see sync.ts).
|
|
514
536
|
// Record the friendly company slug, or null for the personal vault so the
|
|
515
537
|
// menubar shows "Personal" — never the raw prs_/cmp_ UID.
|
|
@@ -856,7 +878,11 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
856
878
|
}
|
|
857
879
|
if (freshContentConverged) {
|
|
858
880
|
const lstat = fs.lstatSync(absolutePath);
|
|
859
|
-
updateEntry(run.journal, relativePath, localHash, lstat.size, "up",
|
|
881
|
+
updateEntry(run.journal, relativePath, localHash, lstat.size, "up", absolutePath, {
|
|
882
|
+
remoteEtag: remoteMeta.etag,
|
|
883
|
+
mtimeMs: lstat.mtimeMs,
|
|
884
|
+
kind: item.kind,
|
|
885
|
+
});
|
|
860
886
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
861
887
|
counters.filesSkipped++;
|
|
862
888
|
return;
|
|
@@ -922,7 +948,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
922
948
|
const { etag } = isSymlinkUpload
|
|
923
949
|
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
924
950
|
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
925
|
-
updateEntry(run.journal, relativePath, localHash, size, "up", etag, mtimeMs,
|
|
951
|
+
updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, kind: item.kind });
|
|
926
952
|
if (run.message) {
|
|
927
953
|
run.journal.files[relativePath] = {
|
|
928
954
|
...run.journal.files[relativePath],
|
|
@@ -1070,19 +1096,32 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1070
1096
|
}
|
|
1071
1097
|
try {
|
|
1072
1098
|
const entry = run.journal.files[relativePath];
|
|
1073
|
-
|
|
1099
|
+
// Last-second absence re-check. Applies to EVERY propagated delete,
|
|
1100
|
+
// intent-backed or etag-only: a pull leg or the user can recreate the
|
|
1101
|
+
// file between planning and applying, and deleting it remotely then
|
|
1102
|
+
// destroys a file that exists locally right now. This used to sit inside
|
|
1103
|
+
// the intent-version check, which meant it was skipped for any item
|
|
1104
|
+
// carrying `intentVersion: null` — harmless while that only covered
|
|
1105
|
+
// litter and decommission, but not once etag-only candidates travel that
|
|
1106
|
+
// way too.
|
|
1107
|
+
//
|
|
1108
|
+
// Codec boundary: journal keys are canonical; the probe must use the
|
|
1109
|
+
// ENCODED local path or a win32 colon key always looks absent (a raw `:`
|
|
1110
|
+
// name can never exist on disk) and the remote delete proceeds while the
|
|
1111
|
+
// encoded local file is still present.
|
|
1112
|
+
const recreatedLocally = !item.decommission &&
|
|
1113
|
+
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
|
|
1114
|
+
const intentChanged = !item.decommission &&
|
|
1074
1115
|
item.intentVersion !== null &&
|
|
1075
1116
|
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1076
|
-
entry.localDeleteIntent.version !== item.intentVersion
|
|
1077
|
-
|
|
1078
|
-
// absence re-check must probe the ENCODED local path or a win32
|
|
1079
|
-
// colon key always looks absent (a raw `:` name can never exist
|
|
1080
|
-
// on disk) and the remote delete proceeds while the encoded local
|
|
1081
|
-
// file is still present.
|
|
1082
|
-
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath)))) {
|
|
1117
|
+
entry.localDeleteIntent.version !== item.intentVersion);
|
|
1118
|
+
if (recreatedLocally || intentChanged) {
|
|
1083
1119
|
if (entry?.localDeleteIntent) {
|
|
1084
1120
|
delete entry.localDeleteIntent;
|
|
1085
1121
|
}
|
|
1122
|
+
const refusalReason = recreatedLocally
|
|
1123
|
+
? "recreated-locally"
|
|
1124
|
+
: "intent-changed";
|
|
1086
1125
|
counters.filesRefusedStale++;
|
|
1087
1126
|
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1088
1127
|
filesRefusedStalePaths.push(relativePath);
|
|
@@ -1092,13 +1131,13 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1092
1131
|
path: relativePath,
|
|
1093
1132
|
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1094
1133
|
remoteEtag: "<not-checked>",
|
|
1095
|
-
reason:
|
|
1134
|
+
reason: refusalReason,
|
|
1096
1135
|
});
|
|
1097
1136
|
pathResults.push({
|
|
1098
1137
|
path: relativePath,
|
|
1099
1138
|
status: "refused",
|
|
1100
1139
|
operation: "delete",
|
|
1101
|
-
reason:
|
|
1140
|
+
reason: refusalReason,
|
|
1102
1141
|
});
|
|
1103
1142
|
continue;
|
|
1104
1143
|
}
|
|
@@ -1431,6 +1470,29 @@ function defaultConsoleLogger(event) {
|
|
|
1431
1470
|
// the summary banner.
|
|
1432
1471
|
console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
|
|
1433
1472
|
}
|
|
1473
|
+
else if (event.reason === "missing-delete-intent") {
|
|
1474
|
+
// NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
|
|
1475
|
+
// is the `<not-checked>` sentinel, and `journalEtag` is the entry's
|
|
1476
|
+
// real recorded etag. Printing them under the `stale-etag` wording
|
|
1477
|
+
// (which this branch used to fall through to) reads as an etag
|
|
1478
|
+
// conflict and has repeatedly sent operators chasing a currency bug
|
|
1479
|
+
// that does not exist — the actual cause is that no watcher observed
|
|
1480
|
+
// the removal, so no delete authorization was ever recorded.
|
|
1481
|
+
console.error(` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
|
|
1482
|
+
`(the sync watcher did not observe this removal, so the delete was never authorized)`);
|
|
1483
|
+
}
|
|
1484
|
+
else if (event.reason === "recreated-locally") {
|
|
1485
|
+
console.error(` ⚠ recreated-locally, kept on remote: ${event.path} ` +
|
|
1486
|
+
`(the file exists locally again, so the delete was abandoned)`);
|
|
1487
|
+
}
|
|
1488
|
+
else if (event.reason === "divergent-local") {
|
|
1489
|
+
// Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
|
|
1490
|
+
// recorded etag was stamped only to silence a re-fired conflict; the
|
|
1491
|
+
// local copy never matched it, so propagating the delete would destroy
|
|
1492
|
+
// divergent remote work.
|
|
1493
|
+
console.error(` ⚠ divergent-local, kept on remote: ${event.path} ` +
|
|
1494
|
+
`(the local copy never matched the recorded remote version; pull to reconcile first)`);
|
|
1495
|
+
}
|
|
1434
1496
|
else {
|
|
1435
1497
|
console.error(` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`);
|
|
1436
1498
|
}
|
|
@@ -2260,6 +2322,40 @@ companyScoped = true) {
|
|
|
2260
2322
|
relativeKey.startsWith(`${root}/`));
|
|
2261
2323
|
if (!inScope)
|
|
2262
2324
|
continue;
|
|
2325
|
+
// A tombstoned entry has ALREADY had its disposition decided — by a scope
|
|
2326
|
+
// shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
|
|
2327
|
+
// intentional-delete record. It must never be re-evaluated as a fresh
|
|
2328
|
+
// delete candidate.
|
|
2329
|
+
//
|
|
2330
|
+
// This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
|
|
2331
|
+
// KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
|
|
2332
|
+
// what was there), and the shrink removes the file locally. Every one of
|
|
2333
|
+
// those entries therefore presents as locally-absent, intent-less and
|
|
2334
|
+
// etag-current — which, now that authorization is etag-only, is an
|
|
2335
|
+
// instruction to delete another member's files out of the shared company
|
|
2336
|
+
// vault, for the crime of having narrowed one's own sync scope.
|
|
2337
|
+
//
|
|
2338
|
+
// Until now the ONLY thing standing in the way was `shouldSync` happening
|
|
2339
|
+
// to carry a `prefixSet` (see `createPushRunContext`); any run that
|
|
2340
|
+
// resolved no prefix set — a degraded scope lookup, a caller that does not
|
|
2341
|
+
// set one — would have walked straight through. That is far too load-
|
|
2342
|
+
// bearing for an implicit dependency, so the invariant is stated here
|
|
2343
|
+
// directly.
|
|
2344
|
+
//
|
|
2345
|
+
// `local-delete` is the ONE reason that must pass: the pull leg stamps it
|
|
2346
|
+
// as a deliberate producer hand-off ("the user removed this; push leg,
|
|
2347
|
+
// finish the job"), and skipping it would strand exactly the deletes this
|
|
2348
|
+
// change exists to enable. Every other reason — scope_shrink,
|
|
2349
|
+
// narrow_apply, manual, or an absent/unrecognised one — means "stop
|
|
2350
|
+
// tracking, do NOT touch the remote", so the test is written fail-closed:
|
|
2351
|
+
// only the explicit hand-off is let through.
|
|
2352
|
+
//
|
|
2353
|
+
// Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
|
|
2354
|
+
// numerator nor the denominator of the bulk-asymmetry ratio. Excluding
|
|
2355
|
+
// them from the denominator can only make the breaker MORE likely to trip,
|
|
2356
|
+
// which is the safe direction.
|
|
2357
|
+
if (isTombstone(entry) && entry.removedReason !== "local-delete")
|
|
2358
|
+
continue;
|
|
2263
2359
|
inScopeJournalEntries++;
|
|
2264
2360
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2265
2361
|
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
@@ -2335,11 +2431,78 @@ companyScoped = true) {
|
|
|
2335
2431
|
continue;
|
|
2336
2432
|
}
|
|
2337
2433
|
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2338
|
-
|
|
2339
|
-
|
|
2434
|
+
// A delete intent is minted ONLY by a live watcher, at the instant it
|
|
2435
|
+
// observes the unlink. Three of the four ways HQ syncs never construct a
|
|
2436
|
+
// watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
|
|
2437
|
+
// now` is one-shot, and the desktop app drops `--event-push` (and with it
|
|
2438
|
+
// the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
|
|
2439
|
+
// an intent can never exist, so requiring one made deletes structurally
|
|
2440
|
+
// impossible rather than merely gated. Files deleted while no watcher was
|
|
2441
|
+
// running also stranded permanently: the file is already gone, so no
|
|
2442
|
+
// watcher can ever retroactively witness it.
|
|
2443
|
+
//
|
|
2444
|
+
// `currency-gated` therefore authorizes on ETag currency alone. The
|
|
2445
|
+
// per-file question it answers is unchanged — "is the remote object still
|
|
2446
|
+
// exactly what this journal recorded, i.e. has no peer touched it since"
|
|
2447
|
+
// — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
|
|
2448
|
+
// never destroyed.
|
|
2449
|
+
//
|
|
2450
|
+
// The whole-set question ("does my local mirror look catastrophically
|
|
2451
|
+
// empty") is NOT answered by the ETag, and must not be conflated with it:
|
|
2452
|
+
// in the steady state nobody has overwritten anything, so a moved hqRoot,
|
|
2453
|
+
// unmounted volume, fresh clone or partial restore presents mass-ENOENT
|
|
2454
|
+
// with perfectly matching ETags. That is exactly how the 2026-05-25
|
|
2455
|
+
// indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
|
|
2456
|
+
// breaker below is the guard for that class and is deliberately left
|
|
2457
|
+
// intact — these candidates are counted into `bulkCandidatePicks` on the
|
|
2458
|
+
// SAME line as intent-backed ones, so relaxing the per-file gate cannot
|
|
2459
|
+
// weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
|
|
2460
|
+
// (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
|
|
2461
|
+
//
|
|
2462
|
+
// `owned-only` keeps requiring an intent: it is the documented rollback
|
|
2463
|
+
// knob, and an operator who selects it is asking for the stricter path.
|
|
2464
|
+
if (policy === "owned-only") {
|
|
2465
|
+
// Still counted into the breaker exactly as before: an owned-only run
|
|
2466
|
+
// whose mirror has gone missing must trip the whole-set guard even
|
|
2467
|
+
// though each individual key is refused for want of an intent.
|
|
2468
|
+
if (entry.direction === "up") {
|
|
2469
|
+
bulkCandidatePicks++;
|
|
2470
|
+
intentlessKeys.add(relativeKey);
|
|
2471
|
+
}
|
|
2472
|
+
plan.refusedStale.push({
|
|
2473
|
+
key: relativeKey,
|
|
2474
|
+
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|
|
2475
|
+
remoteEtag: "<not-checked>",
|
|
2476
|
+
reason: "missing-delete-intent",
|
|
2477
|
+
});
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
if (policy === "currency-gated") {
|
|
2340
2481
|
bulkCandidatePicks++;
|
|
2341
2482
|
intentlessKeys.add(relativeKey);
|
|
2483
|
+
// No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
|
|
2484
|
+
// for the same reason an intent-backed legacy entry is (below).
|
|
2485
|
+
if (!entry.remoteEtag) {
|
|
2486
|
+
plan.refusedStale.push({
|
|
2487
|
+
key: relativeKey,
|
|
2488
|
+
journalEtag: "<legacy-no-etag>",
|
|
2489
|
+
remoteEtag: "<unknown>",
|
|
2490
|
+
reason: "legacy-no-etag",
|
|
2491
|
+
});
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
headCandidates.push({
|
|
2495
|
+
key: relativeKey,
|
|
2496
|
+
journalEtag: entry.remoteEtag,
|
|
2497
|
+
intentVersion: null,
|
|
2498
|
+
});
|
|
2499
|
+
continue;
|
|
2342
2500
|
}
|
|
2501
|
+
// policy === "all" is deliberately NOT relaxed here. It already skips the
|
|
2502
|
+
// bulk-asymmetry breaker, so letting it skip the intent gate too would
|
|
2503
|
+
// make it a wholly unguarded mass delete — a strictly larger blast radius
|
|
2504
|
+
// than this change is scoped to. It keeps refusing intent-less entries
|
|
2505
|
+
// exactly as before.
|
|
2343
2506
|
plan.refusedStale.push({
|
|
2344
2507
|
key: relativeKey,
|
|
2345
2508
|
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|