@indigoai-us/hq-cloud 6.14.46 → 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 +214 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +595 -28
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -5
- 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/ignore.d.ts +38 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +55 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +50 -0
- package/dist/ignore.test.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/dist/s3.test.js +6 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +670 -28
- package/src/cli/share.ts +234 -34
- package/src/cli/sync.ts +29 -15
- package/src/ignore.test.ts +55 -0
- package/src/ignore.ts +57 -0
- package/src/journal.test.ts +44 -1
- package/src/journal.ts +69 -4
- package/src/s3.test.ts +6 -0
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,8 +12,8 @@ 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";
|
|
16
|
-
import { createIgnoreFilter, isExpectedIgnore, isWithinSizeLimit } from "../ignore.js";
|
|
15
|
+
import { readJournal, writeJournal, hashFile, hashSymlinkTarget, updateEntry, removeEntry, isTombstone, normalizeEtag, PERSONAL_VAULT_JOURNAL_SLUG, migratePersonalVaultJournal, } from "../journal.js";
|
|
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";
|
|
19
19
|
import { resolveConflict } from "./conflict.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.
|
|
@@ -620,6 +642,35 @@ async function buildSharePlans(run) {
|
|
|
620
642
|
emitUnreachablePathEvent(run);
|
|
621
643
|
throw new UnreachablePushPathsError(fatal, run.syncRoot);
|
|
622
644
|
}
|
|
645
|
+
// Control-character key filter (macOS Finder Icon\r incident). A key
|
|
646
|
+
// containing a control character can never be stored — validateVaultUploadKey
|
|
647
|
+
// rejects it with INVALID_KEY_CONTROL_CHARS — so planning an upload for one
|
|
648
|
+
// only buys a guaranteed throw inside executeUploads, which lands in the
|
|
649
|
+
// generic catch and emits `type: "error"`. That is the failure this filter
|
|
650
|
+
// exists to stop: a non-empty errors[] makes the runner return
|
|
651
|
+
// PARTIAL_SYNC_EXIT, so ONE such file on disk marks EVERY sync pass failed
|
|
652
|
+
// forever. A macOS tree seeded with Finder `Icon\r` files produced 100+ per
|
|
653
|
+
// run, wedging sync indefinitely.
|
|
654
|
+
//
|
|
655
|
+
// Structurally identical to the size-limit skip in executeUploads: a
|
|
656
|
+
// permanently-invalid path is a benign skip, never an error. Symmetric with
|
|
657
|
+
// the pull leg, which has classified such keys since the 2026-07-11 incident
|
|
658
|
+
// (see computePullPlan's classifyVaultKey call). Applied before the mode
|
|
659
|
+
// branch below so it covers personal and company vaults alike.
|
|
660
|
+
//
|
|
661
|
+
// Finder icon files are normally already gone by here — createIgnoreFilter
|
|
662
|
+
// drops them during the walk. This stays as the general guard for any other
|
|
663
|
+
// control-char path, and as defense for callers that bypass the walker.
|
|
664
|
+
const controlCharFiltered = collected.filter((entry) => {
|
|
665
|
+
if (!hasControlCharacters(entry.relativePath))
|
|
666
|
+
return true;
|
|
667
|
+
run.emit({
|
|
668
|
+
type: "skip-invalid-scoped-key",
|
|
669
|
+
path: entry.relativePath,
|
|
670
|
+
errorCode: "INVALID_KEY_CONTROL_CHARS",
|
|
671
|
+
});
|
|
672
|
+
return false;
|
|
673
|
+
});
|
|
623
674
|
// Scope-invalid key filter (incident 2026-07-11). In company mode the sync
|
|
624
675
|
// root IS the company folder, so a local entry whose vault key starts with
|
|
625
676
|
// `companies/` can only come from a stale doubled local tree
|
|
@@ -632,7 +683,7 @@ async function buildSharePlans(run) {
|
|
|
632
683
|
// of this leg ever re-uploading it. Personal vaults legitimately carry
|
|
633
684
|
// `companies/{slug}/…` keys, so this only applies in company mode.
|
|
634
685
|
const filesToShare = run.options.personalMode === true
|
|
635
|
-
?
|
|
686
|
+
? controlCharFiltered.filter((entry) => {
|
|
636
687
|
// `core/<type>` symlinks are generated projections of canonical
|
|
637
688
|
// personal/package content. Reindex or package wiring owns their
|
|
638
689
|
// lifecycle, so uploading them creates remote objects that a later
|
|
@@ -652,7 +703,7 @@ async function buildSharePlans(run) {
|
|
|
652
703
|
}
|
|
653
704
|
return false;
|
|
654
705
|
})
|
|
655
|
-
:
|
|
706
|
+
: controlCharFiltered.filter((entry) => {
|
|
656
707
|
if (!entry.relativePath.startsWith("companies/"))
|
|
657
708
|
return true;
|
|
658
709
|
run.emit({
|
|
@@ -827,7 +878,11 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
827
878
|
}
|
|
828
879
|
if (freshContentConverged) {
|
|
829
880
|
const lstat = fs.lstatSync(absolutePath);
|
|
830
|
-
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
|
+
});
|
|
831
886
|
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
832
887
|
counters.filesSkipped++;
|
|
833
888
|
return;
|
|
@@ -893,7 +948,7 @@ async function executeUploads(run, pushPlan, counters, conflictPaths) {
|
|
|
893
948
|
const { etag } = isSymlinkUpload
|
|
894
949
|
? await uploadSymlink(run.ctx, item.target, relativePath, run.options.author, pc)
|
|
895
950
|
: await uploadFile(run.ctx, absolutePath, relativePath, run.options.author, pc);
|
|
896
|
-
updateEntry(run.journal, relativePath, localHash, size, "up", etag, mtimeMs,
|
|
951
|
+
updateEntry(run.journal, relativePath, localHash, size, "up", absolutePath, { remoteEtag: etag, mtimeMs, kind: item.kind });
|
|
897
952
|
if (run.message) {
|
|
898
953
|
run.journal.files[relativePath] = {
|
|
899
954
|
...run.journal.files[relativePath],
|
|
@@ -1041,19 +1096,32 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1041
1096
|
}
|
|
1042
1097
|
try {
|
|
1043
1098
|
const entry = run.journal.files[relativePath];
|
|
1044
|
-
|
|
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 &&
|
|
1045
1115
|
item.intentVersion !== null &&
|
|
1046
1116
|
(!hasCurrentLocalDeleteIntent(entry) ||
|
|
1047
|
-
entry.localDeleteIntent.version !== item.intentVersion
|
|
1048
|
-
|
|
1049
|
-
// absence re-check must probe the ENCODED local path or a win32
|
|
1050
|
-
// colon key always looks absent (a raw `:` name can never exist
|
|
1051
|
-
// on disk) and the remote delete proceeds while the encoded local
|
|
1052
|
-
// file is still present.
|
|
1053
|
-
!isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath)))) {
|
|
1117
|
+
entry.localDeleteIntent.version !== item.intentVersion);
|
|
1118
|
+
if (recreatedLocally || intentChanged) {
|
|
1054
1119
|
if (entry?.localDeleteIntent) {
|
|
1055
1120
|
delete entry.localDeleteIntent;
|
|
1056
1121
|
}
|
|
1122
|
+
const refusalReason = recreatedLocally
|
|
1123
|
+
? "recreated-locally"
|
|
1124
|
+
: "intent-changed";
|
|
1057
1125
|
counters.filesRefusedStale++;
|
|
1058
1126
|
if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
|
|
1059
1127
|
filesRefusedStalePaths.push(relativePath);
|
|
@@ -1063,13 +1131,13 @@ async function executeDeletes(run, deletePlan, decommissionPlan, counters, files
|
|
|
1063
1131
|
path: relativePath,
|
|
1064
1132
|
journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
|
|
1065
1133
|
remoteEtag: "<not-checked>",
|
|
1066
|
-
reason:
|
|
1134
|
+
reason: refusalReason,
|
|
1067
1135
|
});
|
|
1068
1136
|
pathResults.push({
|
|
1069
1137
|
path: relativePath,
|
|
1070
1138
|
status: "refused",
|
|
1071
1139
|
operation: "delete",
|
|
1072
|
-
reason:
|
|
1140
|
+
reason: refusalReason,
|
|
1073
1141
|
});
|
|
1074
1142
|
continue;
|
|
1075
1143
|
}
|
|
@@ -1402,6 +1470,29 @@ function defaultConsoleLogger(event) {
|
|
|
1402
1470
|
// the summary banner.
|
|
1403
1471
|
console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
|
|
1404
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
|
+
}
|
|
1405
1496
|
else {
|
|
1406
1497
|
console.error(` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`);
|
|
1407
1498
|
}
|
|
@@ -2231,6 +2322,40 @@ companyScoped = true) {
|
|
|
2231
2322
|
relativeKey.startsWith(`${root}/`));
|
|
2232
2323
|
if (!inScope)
|
|
2233
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;
|
|
2234
2359
|
inScopeJournalEntries++;
|
|
2235
2360
|
const localPath = localPathForVaultKey(syncRoot, relativeKey);
|
|
2236
2361
|
// Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
|
|
@@ -2306,11 +2431,78 @@ companyScoped = true) {
|
|
|
2306
2431
|
continue;
|
|
2307
2432
|
}
|
|
2308
2433
|
if (!hasCurrentLocalDeleteIntent(entry)) {
|
|
2309
|
-
|
|
2310
|
-
|
|
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") {
|
|
2311
2481
|
bulkCandidatePicks++;
|
|
2312
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;
|
|
2313
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.
|
|
2314
2506
|
plan.refusedStale.push({
|
|
2315
2507
|
key: relativeKey,
|
|
2316
2508
|
journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
|