From a8594efd8e9c97e1cc5dd8a9c609f0a1d0e62455 Mon Sep 17 00:00:00 2001 From: test Date: Tue, 4 Aug 2026 21:03:41 +0000 Subject: [PATCH 1/3] Returning gate keeps managed truthful when the merged config is invalid (#605) evaluateReturningGate returned `{ action: 'first-run', managed: false }` whenever the config was missing or failed validation, before it looked at `report.layered.hasCentral`. A machine with a central layer whose merged config stops validating (a server-side config change, client/server schema drift) was therefore reported as an unmanaged first run: the orchestrator never computed the locked set, and the picker rendered the org's rows editable. Picking one composed it into the local layer, which is exactly the central/local collision LLP 0129 #join-before-picker exists to avoid. Derive `managed` from the central layer before the early return, and compute the locked set on the first-run path when the gate reports a managed machine. Which gate or screen is shown is unchanged; the broader UX question from the issue (should an invalid config on a managed machine show the returning gate with a diagnostic instead?) stays open. Co-Authored-By: Claude --- src/core/cli/wizard/fork.js | 17 ++++++++++++--- src/core/cli/wizard/index.js | 10 +++++++++ test/core/cli/wizard/fork.test.js | 21 ++++++++++++++++++ test/core/cli/wizard/index.test.js | 35 ++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/core/cli/wizard/fork.js b/src/core/cli/wizard/fork.js index 4a8f7788..70269831 100644 --- a/src/core/cli/wizard/fork.js +++ b/src/core/cli/wizard/fork.js @@ -124,6 +124,8 @@ export async function legacyForkPrompt(opts, options) { * * A missing or invalid config is the first-run path, not the gate: the * caller falls straight through to `runWizardFork` (no pathway preset). + * `managed` is still reported truthfully on that path, so a machine with + * a central layer keeps its org rows locked (see the derivation below). * Once a valid config exists, a **managed** machine (the merged config * carries a central layer, LLP 0031) offers a scoped "adjust what this * machine collects" entry instead of dropping Reconfigure outright - no @@ -148,12 +150,21 @@ export async function evaluateReturningGate(opts) { const collectStatus = opts.collectStatus ?? collectHypAwareStatus const report = await collectStatus(/** @type {CollectStatusOptions} */ ({ env: opts.env, runtime: opts.runtime })) + // Read `managed` before the first-run early return: a central layer on + // disk is a property of the machine (LLP 0031), not of whether the + // merged config currently validates. A central layer that stops merging + // cleanly (a server-side config change, client/server schema drift) + // still owns its rows, and the caller reads `managed` to decide whether + // to compute the locked set at all. Reporting `false` here left the + // org's rows editable, so picking one composed it into the local layer. + // @ref LLP 0129#join-before-picker [implements]: central rows lock whenever a central layer exists, invalid merge included + const managed = !!(report.layered && report.layered.hasCentral) + if (!report.configExists || !report.configValid) { - log.info('wizard.returning_gate', { [Attr.COMPONENT]: 'wizard', action: 'first-run', managed: false }) - return { action: 'first-run', managed: false, report } + log.info('wizard.returning_gate', { [Attr.COMPONENT]: 'wizard', action: 'first-run', managed }) + return { action: 'first-run', managed, report } } - const managed = !!(report.layered && report.layered.hasCentral) renderConfigSummary({ report, locked: managed, stdout: opts.stdout }) const options = buildReturningGateOptions(managed) const action = await promptReturningGateChoice(opts, options, managed) diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 690923d1..a3d5bd7a 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -83,6 +83,16 @@ export async function runInitWizard(opts) { pathway = 'scoped' managed = true locked = await computeLockedSafe(catalog, opts) + } else if (gate.action === 'first-run' && gate.managed) { + // A managed machine whose config is missing or fails to merge lands + // on the first-run path (the gate has no config to summarise), but + // the central layer on disk still owns its rows. Lock them here too: + // an editable org row that the user picks composes into the local + // layer, and the next central pull overrides or collides with it. + // The pathway stays unset, so the fork still runs. + // @ref LLP 0129#join-before-picker [implements]: the first-run path locks the org rows from the on-disk central layer rather than offering them for composition + managed = true + locked = await computeLockedSafe(catalog, opts) } // 'first-run' and a solo machine's 'reconfigure' both enter here. diff --git a/test/core/cli/wizard/fork.test.js b/test/core/cli/wizard/fork.test.js index 784c8587..91542ce0 100644 --- a/test/core/cli/wizard/fork.test.js +++ b/test/core/cli/wizard/fork.test.js @@ -127,6 +127,27 @@ test('evaluateReturningGate: an invalid config is also first-run', async () => { assert.equal(gate.action, 'first-run') }) +// A central layer that stops merging cleanly (a server-side config change, +// client/server schema drift) must not relabel the machine as unmanaged: +// the caller reads `managed` to decide whether to lock the org's rows, and +// an editable org row composes into the local layer. +// @ref LLP 0129#join-before-picker [tests]: +test('evaluateReturningGate: a managed machine with an invalid config is still managed on the first-run path', async () => { + const { opts } = ctxWithStdin('\n') + opts.collectStatus = async () => fixtureReport({ configValid: false, hasCentral: true }) + const gate = await evaluateReturningGate(opts) + assert.equal(gate.action, 'first-run') + assert.equal(gate.managed, true) +}) + +test('evaluateReturningGate: a managed machine with no config at all is still managed', async () => { + const { opts } = ctxWithStdin('\n') + opts.collectStatus = async () => fixtureReport({ configExists: false, hasCentral: true }) + const gate = await evaluateReturningGate(opts) + assert.equal(gate.action, 'first-run') + assert.equal(gate.managed, true) +}) + test('evaluateReturningGate: managed machine, choosing the scoped entry presets a scoped re-entry (no fork)', async () => { const { opts, stdout } = ctxWithStdin('1\n') opts.collectStatus = async () => fixtureReport({ hasCentral: true }) diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js index bfa7bc13..ad1b19f4 100644 --- a/test/core/cli/wizard/index.test.js +++ b/test/core/cli/wizard/index.test.js @@ -42,6 +42,21 @@ function emptyCatalog() { }) } +/** + * Write a central layer (the join seed slot, LLP 0031) under a wizard + * home, so the locked-set computation resolves it from disk exactly as it + * does on a real enrolled machine. + * + * @param {string} home + * @param {string[]} plugins + */ +async function seedCentralLayer(home, plugins) { + const control = path.join(home, '.hyp', 'hypaware', 'config-control') + await fs.mkdir(control, { recursive: true }) + const config = { version: 2, plugins: plugins.map((name) => ({ name, enabled: true, config: {} })) } + await fs.writeFile(path.join(control, 'seed.json'), JSON.stringify(config)) +} + /** A completed pick result the finale and configure stubs can consume. */ function pickResult(over = {}) { return /** @type {any} */ ({ @@ -144,6 +159,26 @@ test('runInitWizard: scoped re-entry skips the fork and picks scoped + managed', assert.equal(result.pathway, 'scoped') }) +// A managed machine whose merged config no longer validates falls to the +// first-run path, but the central layer on disk still owns its rows. The +// locked set has to be computed there too, or the picker offers the org's +// rows for free composition into the local layer. +// @ref LLP 0129#join-before-picker [tests]: +test('runInitWizard: a managed first run locks the org rows from the on-disk central layer', async () => { + const home = await tmpHome() + await seedCentralLayer(home, ['@hypaware/claude']) + const catalog = emptyCatalog() + catalog.pickerDescriptors.set('claude', { plugin: '@hypaware/claude', id: 'claude', label: 'Claude' }) + const { opts } = wizardOpts(home, { + catalog, + gate: async () => ({ action: 'first-run', managed: true, report: {} }), + }) + const result = await runInitWizard(opts) + assert.equal(result.exitCode, 0) + assert.deepEqual(opts._pickOpts.locked, ['claude']) + assert.equal(opts._pickOpts.managed, true) +}) + // --- the fork/join loop --- test('runInitWizard: local pathway runs pick -> configure -> finale, no join', async () => { From fd15e1587118303f36469fb5af0d0f83f1b8495c Mon Sep 17 00:00:00 2001 From: test Date: Tue, 4 Aug 2026 22:02:22 +0000 Subject: [PATCH 2/3] computeCentralLockedSources doc names its third caller (#605 review) The first-run path on a managed machine now calls it too. The docstring enumerated only the join phase and the scoped re-entry. Co-Authored-By: Claude --- src/core/cli/wizard/join.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/cli/wizard/join.js b/src/core/cli/wizard/join.js index ea5b1a0b..31cd2e66 100644 --- a/src/core/cli/wizard/join.js +++ b/src/core/cli/wizard/join.js @@ -118,8 +118,11 @@ async function runJoinFlow(opts, span) { * two-layer config from disk and keep every catalog picker id whose * owning plugin classifies `'central'`. The pick phase locks exactly this * set (LLP 0129 #join-before-picker). Shared by the join phase (after - * convergence) and the wizard's scoped re-entry, where no join runs but a - * managed machine's org rows must still render locked. + * convergence) and by every wizard entry that reaches the picker on an + * already-managed machine without a join: the scoped re-entry, and the + * first-run path a managed machine falls to when its merged config no + * longer validates. In both, no join runs but the org's rows must still + * render locked. * * The classifier needs the catalog as its third argument to resolve a * source id to its owning plugin (the design sketch elides it for From aebb3f229b44f642f384cbfb55102515d3e49f1b Mon Sep 17 00:00:00 2001 From: test Date: Tue, 4 Aug 2026 22:40:17 +0000 Subject: [PATCH 3/3] Locked-set doc and gate `managed` contract name the first-run path (#605 review) Round-2 doc-honesty follow-ups to the same class of finding round 1 fixed in join.js, in the two places it did not reach: - `computeLockedSafe` (src/core/cli/wizard/index.js) was documented as "the scoped re-entry's locked-set computation"; this PR gives it a second caller, the managed first-run path. - `ReturningGateResult.managed` (src/core/cli/wizard/types.d.ts) was documented as "true when the merged config carries a central layer". Decoupling `managed` from the merged config's validity is the whole point of this change, so the contract now says a central layer on disk, independently of whether the merge exists or validates. - The `{configExists: false, hasCentral: true}` fork test now says in place that the pairing is defensive guard coverage, not a state `collectHypAwareStatus` emits, so nobody later reads it as evidence. Doc/comment only; no behavior change. Co-Authored-By: Claude --- src/core/cli/wizard/index.js | 9 ++++++--- src/core/cli/wizard/types.d.ts | 7 ++++++- test/core/cli/wizard/fork.test.js | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index a3d5bd7a..119e758b 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -395,9 +395,12 @@ async function loadWizardCatalog() { } /** - * The scoped re-entry's locked-set computation, guarded: a resolution - * failure renders an unlocked picker (additions still compose; the - * export seam, not the picker, enforces the org boundary, LLP 0132). + * The locked-set computation for every entry that reaches the picker on + * an already-managed machine without a join (the scoped re-entry, and the + * first-run path a managed machine falls to when its merged config no + * longer validates), guarded: a resolution failure renders an unlocked + * picker (additions still compose; the export seam, not the picker, + * enforces the org boundary, LLP 0132). * * @param {PluginCatalog} catalog * @param {Pick} opts diff --git a/src/core/cli/wizard/types.d.ts b/src/core/cli/wizard/types.d.ts index 1074c89d..2f1b08b2 100644 --- a/src/core/cli/wizard/types.d.ts +++ b/src/core/cli/wizard/types.d.ts @@ -52,7 +52,12 @@ export type ReturningGateAction = 'first-run' | 'quit' | 'status' | 'reconfigure export interface ReturningGateResult { action: ReturningGateAction - /** True when the merged config carries a central layer (LLP 0031). */ + /** + * True when a central layer is on disk (LLP 0031), independently of + * whether the merged config currently exists or validates: enrollment + * is a property of the machine, and the org's rows stay locked even on + * the `first-run` path a broken merge falls to. + */ managed: boolean report: HypAwareStatusReport } diff --git a/test/core/cli/wizard/fork.test.js b/test/core/cli/wizard/fork.test.js index 91542ce0..3cff5dc2 100644 --- a/test/core/cli/wizard/fork.test.js +++ b/test/core/cli/wizard/fork.test.js @@ -140,6 +140,12 @@ test('evaluateReturningGate: a managed machine with an invalid config is still m assert.equal(gate.managed, true) }) +// Defensive coverage of the guard's *other* branch, not a state the +// collector emits: `collectHypAwareStatus` sets `configExists` from a +// non-null effective config, and `mergeConfigLayers` always returns one +// once a central layer loaded, so on a real machine `hasCentral` implies +// `configExists`. Pinned so a future rewrite of the `||` cannot make +// `managed` depend on `configExists` again. test('evaluateReturningGate: a managed machine with no config at all is still managed', async () => { const { opts } = ctxWithStdin('\n') opts.collectStatus = async () => fixtureReport({ configExists: false, hasCentral: true })