trackly-cli 0.18.0 → 0.18.1

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.
@@ -0,0 +1,151 @@
1
+ ---
2
+ title: Bind access-review approval to validated proposal receipts
3
+ date: 2026-09-05
4
+ last_updated: 2026-09-05
5
+ category: integration-issues
6
+ module: Trackly Apply access-review facade
7
+ problem_type: integration_issue
8
+ component: mcp_facade
9
+ symptoms:
10
+ - A compact backend proposal could be rejected because the client required display identity fields in both projections
11
+ - Active execution recovery could reject the backend's enabled metadata or expose access_review without its proposal
12
+ - An empty access-review proposal could pass the client response schema and reach approval handling
13
+ - Follow-up proposal caching and key-order-sensitive accessKnowledge comparisons could reject a valid continuation
14
+ - The deferment list reused the execution-wave cap instead of naming the backend's active-deferment limit
15
+ root_cause: missing_validation
16
+ resolution_type: code_fix
17
+ severity: high
18
+ related_components: [api_layer, contract_verifier, documentation]
19
+ tags: [trackly-apply, access-review, proposal-receipt, zod, hosted-contract, fail-closed]
20
+ ---
21
+
22
+ # Bind access-review approval to validated proposal receipts
23
+
24
+ ## Problem
25
+
26
+ The CLI receives an immutable access-review proposal before it can advance an
27
+ Apply execution. Approval must remain bound to that exact server receipt across
28
+ the response validator, the local cache, the hosted contract verifier, and the
29
+ agent-facing skill instructions.
30
+
31
+ ## Symptoms
32
+
33
+ The deployed 3.8.x backend uses a compact local `proposedWave` array and keeps
34
+ the rich approval receipt in `accessProposal.members`. The client previously
35
+ required company, title, provider, and URL fields in both projections, so a real
36
+ response failed before approval. The active endpoint also returns `enabled`,
37
+ `active`, and `preserved`, while a pending access review is hydrated by the
38
+ detail endpoint rather than inlined. The response schemas accepted an empty
39
+ `proposedWave` or `accessProposal` without checking its counts. An access-review
40
+ continuation normally requires a concrete member set, while the all-deferred
41
+ case legitimately has no members and must be recognized by zero available
42
+ candidates plus a positive deferred count. Exact recovery can also be blocked
43
+ by a user deferment with zero members even when available candidates remain;
44
+ that `recovery_blocked_by_user_deferment` rationale is a safe stop/clear path.
45
+ Older 3.8.0 responses may omit the
46
+ optional deferment mapping, so those receipts remain a safe stop/expiry state
47
+ until a refreshed response supplies exact IDs. After an approval returned another
48
+ `nextAction: access_review`, the new proposal was not cached, so the next exact
49
+ approval was rejected locally. Matching nested `accessKnowledge` values by raw
50
+ JSON text also made harmless object key order look like a changed receipt.
51
+ Finally, the hosted verifier selected runtime class members before rejecting
52
+ computed keys, allowing a computed member to shadow the reviewed method without
53
+ being inspected.
54
+
55
+ ## Root cause
56
+
57
+ The implementation treated each response branch independently. Schema presence
58
+ checks did not encode nonempty review membership or compact-vs-rich projection
59
+ parity, active recovery did not account for the endpoint's two-step shape, the
60
+ approval cache handled only the first proposal, and identity comparison relied
61
+ on serialization order. The verifier's member lookup likewise happened before
62
+ its computed-key guard.
63
+
64
+ ## What Didn't Work
65
+
66
+ A single proposal-response model could not safely cover start, per-execution
67
+ reads, active recovery, and advance because those endpoints return different
68
+ identity and metadata envelopes. Passing responses through raw exposed
69
+ unmodelled fields, while forcing every active response through the access-review
70
+ schema rejected ordinary and preserved-terminal recovery. Caching only the
71
+ first proposal, clearing replay state on every refresh, and leaving proposal
72
+ bindings unbounded broke either exact retry behavior or long-running process
73
+ memory safety. Raw JSON serialization made receipt equality depend on object-key
74
+ order. Reusing the execution-wave limit for deferment discovery happened to
75
+ match the backend's current limit of 20, but concealed a separate contract that
76
+ could drift independently.
77
+
78
+ ## Solution
79
+
80
+ - Accept the deployed compact local projection and the optional complete future
81
+ display identity projection, while keeping the rich receipt's immutable
82
+ fields required and matching both projections by ordered job ID and
83
+ `accessKnowledge`.
84
+ - Strictly validate start, advance, and active execution envelopes, preserve
85
+ `enabled`/`active`/`preserved`, and hydrate a pending access review from the
86
+ detail endpoint only when execution revision and `nextAction` still match.
87
+ - Require a nonempty member set for normal access-review proposals, while
88
+ accepting the bounded all-deferred shape with zero available candidates and at
89
+ least one deferred candidate. Also accept an empty
90
+ `recovery_blocked_by_user_deferment` receipt when at least one candidate is
91
+ deferred. Validate optional deferment mappings when supplied; a legacy
92
+ response without mappings cannot authorize a clear action.
93
+ - Validate progress candidate counts against the access proposal counts while
94
+ the response is still an `access_review`; after approval, the proposal is a
95
+ historical receipt and progress is allowed to reflect the newly created wave.
96
+ - Canonicalize nested `accessKnowledge` objects by key before comparing the
97
+ simple and rich proposal representations.
98
+ - Cache a newly returned access-review proposal after a successful approval so
99
+ the next continuation uses its current revision, ordered IDs, and hash. Bound
100
+ pending and replayable proposal bindings with oldest-entry eviction so an
101
+ abandoned execution cannot retain process memory indefinitely.
102
+ - Reject all computed instance members before selecting the hosted runtime
103
+ method, with fixtures for computed methods and fields.
104
+ - Keep the local and adapted Apply skills explicit about
105
+ `accessProposal.approvalHash`, and distinguish creation `jobId` values from
106
+ the discovered `defermentId` required to clear a deferment.
107
+ - Validate deferment discovery against the backend's dedicated active-deferment
108
+ limit rather than coupling it to the numerically equal execution-wave limit.
109
+
110
+ ## Why This Works
111
+
112
+ Each endpoint is parsed through its own strict envelope before its response is
113
+ returned or cached. An access-review continuation must contain both the compact
114
+ proposal and rich receipt, and the validator binds their ordered job IDs,
115
+ contiguous member positions, frozen access knowledge, counts, and approval hash.
116
+ Start and active recovery hydrate the detail endpoint only when execution ID,
117
+ revision, and next action still match, preventing a stale detail response from
118
+ authorizing work. Separate bounded pending and replay caches preserve exact
119
+ same-key idempotency while rejecting a new key or evicting abandoned execution
120
+ state. Canonical object comparison removes serialization-order dependence, and
121
+ the separately named deferment limit prevents unrelated contract constants from
122
+ being coupled accidentally.
123
+
124
+ ## Verification
125
+
126
+ PR #135 merged as `e97309a07a8afdc6eb62b28f4313c538699c72e5`.
127
+ Its CI passed 520 tests and the checked-in contract-fixture run passed 81 tests.
128
+
129
+ Backend PR `#1769` merged as `254e1c6fc3f98e2f3aa8c6492702e9b9480ca22e`
130
+ (head `4c4dab848b1068b1bd9290b14f8d278ec643aca6`) on 2026-09-05 14:00 PDT.
131
+ Production `https://closeai.mba/api/first-test` reports `build_sha`
132
+ `d5a9abf3e12fb384d50ec998d83a2370725ae3e7`, which contains that merge, and
133
+ every hosted path the CLI locks is byte-identical between the two commits.
134
+ Protected migration 507 completed on 2026-09-06 07:13 PDT
135
+ (`providerDefermentsReady: true` on an idempotent rerun after the first request
136
+ outlived the Cloudflare proxy timeout). The hosted contract fixture was
137
+ recaptured from `254e1c6fc3`, and
138
+ `TRACKLY_BACKEND_DIR=<clean checkout at 254e1c6fc3> npm run test:hosted-contract`
139
+ passes with Apply 3.8.1 and the 21-tool public facade at 1.0.0.
140
+
141
+ Still pending: npm publication of 0.18.1 through the merge of PR #136, and the
142
+ backend rollout switch `TRACKLY_APPLY_ACCESS_KNOWLEDGE_ROLLOUT`, which is unset
143
+ in production, so the live protocol still advertises the 3.7.x window; that
144
+ activation is a backend rollout decision outside this repository.
145
+
146
+ ## Prevention
147
+
148
+ When an access-review response shape changes, update the executed schema,
149
+ response cache lifecycle, identity comparison, hosted verifier, both skill
150
+ surfaces, and regression fixtures together. Treat server-provided proposal
151
+ identity and hashes as the only approval authority.
@@ -115,10 +115,10 @@ API-key creation attempts.
115
115
  - **trackly_get_apply_execution_snapshot**: Fetch a compact bounded projection of current members, requested profile keys, mutability, allowed operations, milestones, lease timing, and the authoritative funnel.
116
116
  - **trackly_resume_parked_apply_member**: Resume one parked member only after explicit user instruction; a fresh non-mutating access probe remains required before form mutation.
117
117
  - **trackly_approve_apply_execution_resume**: Approve one exact resume identity for an execution's unchanged original snapshot while preserving immediate per-run local verification before upload.
118
- - **trackly_advance_apply_execution** — Transactionally select the next wave from the execution's original recent-first snapshot for the declared `browserSurface`. Returns the immutable `proposedWave` with each member's frozen order, job ID, company, title, provider, requisition URL, and value-free `accessKnowledge` receipt. Same-key retries return identical identity, order, and rationale. Optional hash-bound `accessReviewApproval` probes exact deferred job IDs after personal deferments are cleared.
118
+ - **trackly_advance_apply_execution** — Transactionally select the next wave from the execution's original recent-first snapshot for the declared `browserSurface`. Returns a compact immutable `proposedWave` with each member's job ID and value-free `accessKnowledge`, plus the rich `accessProposal` receipt with member order, rationale, and approval hash. Same-key retries return identical identity, order, and rationale. Optional hash-bound `accessReviewApproval` probes exact proposed job IDs after personal deferments are cleared.
119
119
  - **trackly_list_apply_access_deferments** — List the current user's persistent job/company/provider access deferments. Returns only scope identities; never provider names, URLs, or raw text.
120
- - **trackly_defer_apply_access** — Persist an explicit user deferment for one Trackly `jobId` at `job`, `company`, or `provider` scope. The server derives company, provider, tenant, origin, and route; provider scope applies across companies, recovery, and replay.
121
- - **trackly_clear_apply_access_deferment** — Clear one deferment listed or created in the current MCP session. Exact same-session replay is idempotent, including for provider-wide user policy; after restart, refresh the active list instead of replaying an undiscovered cleared ID.
120
+ - **trackly_defer_apply_access** — Persist an explicit user deferment for one Trackly `jobId` at `job`, `company`, or `provider` scope. The server derives company, provider, tenant, origin, and route; provider scope applies across companies until cleared; never submit a provider name, URL, or raw text.
121
+ - **trackly_clear_apply_access_deferment** — Clear one deferment listed or created in the current MCP session. Exact same-session replay is idempotent; after restart, refresh the active list instead of replaying an undiscovered cleared ID.
122
122
  - **trackly_record_apply_execution_dispositions** — Record typed, value-free live-probe classifications for the current wave. Every item requires `jobId`, one allowed `classification`, `source: 'live_probe'`, and the exact `batchId`, `memberId`, `runId`, `expectedMemberVersion`, `expectedInspectionEpoch`, and `browserSurface`; cache/static scheduling records are server-owned and cannot be submitted through MCP.
123
123
  - **trackly_stop_apply_execution** — Stop an execution with optimistic revision and idempotency guards.
124
124
  - **trackly_create_apply_batch** — Freeze an exact recent-first set of approved jobs with an idempotency key.
@@ -149,10 +149,10 @@ API-key creation attempts.
149
149
  Apply contract v3 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths are never sent remotely. Resume fingerprints are sent only to authenticated Trackly resume approval and truth-certification endpoints, never observations or employer forms. Version 3.1 also records universal value-free evidence for critical-contact integrity and the manual-submit boundary. Version 3.2 authorizes the exact stored HTTPS origin for jobs Trackly ingested from employer careers sources, without granting redirect, iframe, or hostname-suffix privileges. Version 3.3.1 adds active-batch recovery, epoch-bound observations/outcomes, and truth certification for forms with no resume control. Version 3.4 adds server-owned accessible executions above immutable child batches, typed access dispositions, and an authoritative target-completion funnel.
150
150
 
151
151
  Version 3.8.1 adds access-aware scheduling receipts, `nextAction: access_review`,
152
- hash-bound deferred-probe approval, and job/company/provider deferment tools.
153
- Provider scope is derived from a stable job anchor and applies across companies,
154
- approval, recovery, and replay. Historical OPEN never authorizes form filling;
155
- every selected job still requires a fresh non-mutating live probe.
152
+ hash-bound probe approval, and job/company/provider deferment tools. Provider scope
153
+ applies across companies until explicitly cleared. Historical OPEN never
154
+ authorizes form filling; every selected job still requires a fresh non-mutating
155
+ live probe.
156
156
 
157
157
  Version 3.7 adds public exact-member recovery and scoped review-handoff claims,
158
158
  plus local tab-finalizer and resume-upload proof validators. Recovery candidates
@@ -35,6 +35,7 @@ const APPLY_ACCESS_FRESHNESS_STATES = APPLY_CONTRACT.constants.applyAccessFreshn
35
35
  const APPLY_SCHEDULING_EFFECTS = APPLY_CONTRACT.constants.applySchedulingEffects;
36
36
  const APPLY_ACCESS_DEFERMENT_SCOPES = APPLY_CONTRACT.constants.applyAccessDefermentScopes;
37
37
  const APPLY_ACCESS_MATCHED_SCOPES = APPLY_CONTRACT.constants.applyAccessMatchedScopes;
38
+ const APPLY_ACCESS_DEFERMENT_MAX_ACTIVE = 20;
38
39
  const APPLY_BATCH_MAX_MEMBERS = 100;
39
40
  const APPLY_BATCH_MAX_CHECKPOINTS_PER_REQUEST = 20;
40
41
  const APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT = 25;
@@ -177,30 +178,64 @@ const accessKnowledgeSchema = z.object({
177
178
  evaluatedAt: z.string().datetime(),
178
179
  freshLiveProbeRequired: z.literal(true),
179
180
  }).strict();
181
+ const OPTIONAL_WAVE_DISPLAY_FIELDS = [
182
+ 'jobTitle', 'companyName', 'provider', 'requisitionUrl',
183
+ ];
184
+ function validateOptionalWaveDisplay(member, context) {
185
+ const present = OPTIONAL_WAVE_DISPLAY_FIELDS.filter((field) => member[field] !== undefined);
186
+ if (present.length > 0 && present.length < OPTIONAL_WAVE_DISPLAY_FIELDS.length) {
187
+ context.addIssue({
188
+ code: z.ZodIssueCode.custom,
189
+ path: [present[0]],
190
+ message: 'wave member display identity fields must be complete when projected',
191
+ });
192
+ }
193
+ }
180
194
  const proposedWaveMemberSchema = z.object({
181
195
  jobId: z.number().int().min(1),
182
- memberPosition: z.number().int().min(0),
196
+ // The local CLI projection is intentionally compact: the deployed backend
197
+ // returns only the stable job identity and scheduling receipt here. Rich
198
+ // approval metadata lives in accessProposal.members. The optional identity
199
+ // fields keep the client forward-compatible with the coordinated full
200
+ // identity projection without trusting them over the rich receipt.
201
+ // Protocol 3.8.0 compact receipts omit memberPosition. When a coordinated
202
+ // backend projects it, validate and compare it independently of display
203
+ // identity fields; never reject the deployed compact shape for its absence.
204
+ memberPosition: z.number().int().min(0).optional(),
183
205
  jobTitle: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
184
206
  message: 'jobTitle must contain at most 300 Unicode code points',
185
- }),
207
+ }).optional(),
186
208
  companyName: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
187
209
  message: 'companyName must contain at most 300 Unicode code points',
188
- }),
189
- provider: z.string().regex(SAFE_OBSERVATION_CODE),
210
+ }).optional(),
211
+ provider: z.string().regex(SAFE_OBSERVATION_CODE).optional(),
190
212
  requisitionUrl: z.string().url().max(2048).refine((value) => value.startsWith('https://'), {
191
213
  message: 'requisitionUrl must use HTTPS',
192
- }),
214
+ }).optional(),
193
215
  accessKnowledge: accessKnowledgeSchema,
194
- }).strict();
216
+ }).strict().superRefine(validateOptionalWaveDisplay);
195
217
  const blockedJobDefermentSchema = z.object({
196
218
  jobId: z.number().int().min(1),
197
219
  defermentId: z.number().int().min(1),
198
220
  scope: z.enum(APPLY_ACCESS_DEFERMENT_SCOPES),
199
221
  }).strict();
200
- const richProposedWaveMemberSchema = proposedWaveMemberSchema.extend({
222
+ const richProposedWaveMemberSchema = z.object({
223
+ jobId: z.number().int().min(1),
224
+ memberPosition: z.number().int().min(0),
225
+ jobTitle: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
226
+ message: 'jobTitle must contain at most 300 Unicode code points',
227
+ }).optional(),
228
+ companyName: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
229
+ message: 'companyName must contain at most 300 Unicode code points',
230
+ }).optional(),
231
+ provider: z.string().regex(SAFE_OBSERVATION_CODE).optional(),
232
+ requisitionUrl: z.string().url().max(2048).refine((value) => value.startsWith('https://'), {
233
+ message: 'requisitionUrl must use HTTPS',
234
+ }).optional(),
235
+ accessKnowledge: accessKnowledgeSchema,
201
236
  rationaleCode: z.string().regex(SAFE_OBSERVATION_CODE),
202
237
  receiptHash: z.string().regex(/^[a-f0-9]{64}$/),
203
- }).strict();
238
+ }).strict().superRefine(validateOptionalWaveDisplay);
204
239
  const accessProposalSchema = z.object({
205
240
  proposalId: z.number().int().min(1),
206
241
  approvalHash: z.string().regex(/^[a-f0-9]{64}$/),
@@ -218,6 +253,30 @@ const accessProposalSchema = z.object({
218
253
  .optional(),
219
254
  members: z.array(richProposedWaveMemberSchema).max(APPLY_EXECUTION_MAX_TARGET),
220
255
  }).strict().superRefine((proposal, context) => {
256
+ const allDeferred = proposal.rationaleCode === 'all_candidates_user_deferred';
257
+ const recoveryBlocked = proposal.rationaleCode === 'recovery_blocked_by_user_deferment';
258
+ if (proposal.members.length === 0
259
+ && ((!allDeferred && !recoveryBlocked)
260
+ || (allDeferred
261
+ && (proposal.availableCandidateCount !== 0 || proposal.deferredCandidateCount < 1))
262
+ || (recoveryBlocked && proposal.deferredCandidateCount < 1))) {
263
+ context.addIssue({
264
+ code: z.ZodIssueCode.custom,
265
+ path: ['members'],
266
+ message: 'empty accessProposal members require no available candidates and at least one deferred candidate',
267
+ });
268
+ }
269
+ if (proposal.members.length > 0 && (allDeferred || recoveryBlocked)) {
270
+ context.addIssue({
271
+ code: z.ZodIssueCode.custom,
272
+ path: ['members'],
273
+ message: 'terminal deferment rationales require an empty accessProposal members array',
274
+ });
275
+ }
276
+ // The initial 3.8.0 backend response omitted this optional mapping. When
277
+ // it is present, the mapping is validated and cached so the agent can offer
278
+ // an exact clear action; when it is absent, the proposal remains a safe
279
+ // stop/expiry state until a refreshed backend receipt supplies IDs.
221
280
  if (new Set(proposal.members.map(({ jobId }) => jobId)).size !== proposal.members.length) {
222
281
  context.addIssue({
223
282
  code: z.ZodIssueCode.custom,
@@ -278,6 +337,20 @@ const compatibleExecutionProgressSchema = executionProgressSchema.extend({
278
337
  submitted: z.number().int().min(0),
279
338
  }).strict().optional(),
280
339
  }).strict();
340
+ function canonicalizeAccessKnowledge(value) {
341
+ if (Array.isArray(value)) return value.map(canonicalizeAccessKnowledge);
342
+ if (value && typeof value === 'object') {
343
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [
344
+ key,
345
+ canonicalizeAccessKnowledge(value[key]),
346
+ ]));
347
+ }
348
+ return value;
349
+ }
350
+ function accessKnowledgeEqual(left, right) {
351
+ return JSON.stringify(canonicalizeAccessKnowledge(left))
352
+ === JSON.stringify(canonicalizeAccessKnowledge(right));
353
+ }
281
354
  function validateMatchingProposal(response, context) {
282
355
  const simpleIds = response.proposedWave.map(({ jobId }) => jobId);
283
356
  const richIds = response.accessProposal.members.map(({ jobId }) => jobId);
@@ -299,12 +372,12 @@ function validateMatchingProposal(response, context) {
299
372
  if (response.proposedWave.some((member, index) => {
300
373
  const rich = response.accessProposal.members[index];
301
374
  return !rich
302
- || member.memberPosition !== rich.memberPosition
303
- || member.jobTitle !== rich.jobTitle
304
- || member.companyName !== rich.companyName
305
- || member.provider !== rich.provider
306
- || member.requisitionUrl !== rich.requisitionUrl
307
- || JSON.stringify(member.accessKnowledge) !== JSON.stringify(rich.accessKnowledge)
375
+ || (member.memberPosition !== undefined && member.memberPosition !== rich.memberPosition)
376
+ || (member.jobTitle !== undefined && member.jobTitle !== rich.jobTitle)
377
+ || (member.companyName !== undefined && member.companyName !== rich.companyName)
378
+ || (member.provider !== undefined && member.provider !== rich.provider)
379
+ || (member.requisitionUrl !== undefined && member.requisitionUrl !== rich.requisitionUrl)
380
+ || !accessKnowledgeEqual(member.accessKnowledge, rich.accessKnowledge)
308
381
  || rich.rationaleCode !== rich.accessKnowledge.rationaleCode;
309
382
  })) {
310
383
  context.addIssue({
@@ -313,6 +386,26 @@ function validateMatchingProposal(response, context) {
313
386
  message: 'accessProposal must bind the exact displayed frozen identities and access knowledge',
314
387
  });
315
388
  }
389
+ if (response.progress?.nextAction === 'access_review'
390
+ && response.progress.availableCandidateCount !== undefined
391
+ && response.progress.availableCandidateCount
392
+ !== response.accessProposal.availableCandidateCount) {
393
+ context.addIssue({
394
+ code: z.ZodIssueCode.custom,
395
+ path: ['progress', 'availableCandidateCount'],
396
+ message: 'progress availableCandidateCount must match accessProposal',
397
+ });
398
+ }
399
+ if (response.progress?.nextAction === 'access_review'
400
+ && response.progress.deferredCandidateCount !== undefined
401
+ && response.progress.deferredCandidateCount
402
+ !== response.accessProposal.deferredCandidateCount) {
403
+ context.addIssue({
404
+ code: z.ZodIssueCode.custom,
405
+ path: ['progress', 'deferredCandidateCount'],
406
+ message: 'progress deferredCandidateCount must match accessProposal',
407
+ });
408
+ }
316
409
  }
317
410
  const proposedWaveResponseSchema = z.object({
318
411
  success: z.literal(true),
@@ -355,16 +448,58 @@ const getExecutionAccessReviewResponseSchema = z.object({
355
448
  accessProposal: accessProposalSchema,
356
449
  }).strict().superRefine(validateMatchingProposal);
357
450
  const activeExecutionAccessReviewResponseSchema = getExecutionAccessReviewResponseSchema.innerType().extend({
451
+ enabled: z.boolean().optional(),
358
452
  active: z.boolean().optional(),
359
453
  preserved: z.boolean().optional(),
360
- }).strict().superRefine(validateMatchingProposal);
454
+ }).strict().superRefine(validateMatchingProposal).superRefine(rejectInactiveExecution);
361
455
  const startExecutionAccessReviewResponseSchema = z.object({
362
456
  success: z.literal(true),
457
+ replay: z.boolean().optional(),
363
458
  execution: applyExecutionSchema,
459
+ candidateCount: z.number().int().min(0).optional(),
364
460
  progress: executionProgressSchema,
365
461
  proposedWave: z.array(proposedWaveMemberSchema).max(APPLY_EXECUTION_MAX_TARGET),
366
462
  accessProposal: accessProposalSchema,
367
463
  }).strict().superRefine(validateMatchingProposal);
464
+ const startExecutionOrdinaryResponseSchema = z.object({
465
+ success: z.literal(true),
466
+ replay: z.boolean().optional(),
467
+ execution: applyExecutionSchema,
468
+ candidateCount: z.number().int().min(0).optional(),
469
+ progress: compatibleExecutionProgressSchema,
470
+ }).strict();
471
+ const startExecutionResponseSchema = z.union([
472
+ startExecutionAccessReviewResponseSchema,
473
+ startExecutionOrdinaryResponseSchema,
474
+ ]);
475
+ function rejectAccessReviewWithoutProposal(response, context) {
476
+ if (response.progress.nextAction === 'access_review'
477
+ && (response.proposedWave === undefined || response.accessProposal === undefined)) {
478
+ context.addIssue({
479
+ code: z.ZodIssueCode.custom,
480
+ path: ['progress', 'nextAction'],
481
+ message: 'access_review responses must include proposedWave and accessProposal',
482
+ });
483
+ }
484
+ }
485
+ function rejectInactiveExecution(response, context) {
486
+ // A preserved terminal execution is intentionally returned with
487
+ // `active: false` for read-only reconciliation. Only reject the
488
+ // contradictory combination where that same response still asks the
489
+ // caller to perform more work. Preserved terminals are valid only with
490
+ // nextAction `none`.
491
+ if (
492
+ response.active === false
493
+ && response.execution !== undefined
494
+ && response.progress?.nextAction !== 'none'
495
+ ) {
496
+ context.addIssue({
497
+ code: z.ZodIssueCode.custom,
498
+ path: ['active'],
499
+ message: 'active=false terminal responses must use nextAction none',
500
+ });
501
+ }
502
+ }
368
503
  const advanceExecutionResponseSchema = z.object({
369
504
  success: z.literal(true),
370
505
  executionId: z.number().int().min(1),
@@ -373,12 +508,34 @@ const advanceExecutionResponseSchema = z.object({
373
508
  revision: z.number().int().min(1),
374
509
  progress: compatibleExecutionProgressSchema,
375
510
  replay: z.boolean().optional(),
376
- }).strict();
377
- const getExecutionResponseSchema = z.object({
511
+ }).strict().superRefine(rejectAccessReviewWithoutProposal);
512
+ const getExecutionResponseObjectSchema = z.object({
378
513
  success: z.literal(true),
379
514
  execution: applyExecutionSchema,
380
515
  progress: compatibleExecutionProgressSchema,
381
516
  }).strict();
517
+ const getExecutionResponseSchema = getExecutionResponseObjectSchema.superRefine(rejectAccessReviewWithoutProposal);
518
+ const activeExecutionOrdinaryResponseSchema = getExecutionResponseObjectSchema.extend({
519
+ enabled: z.boolean().optional(),
520
+ active: z.boolean().optional(),
521
+ preserved: z.boolean().optional(),
522
+ }).strict().superRefine(rejectAccessReviewWithoutProposal).superRefine(rejectInactiveExecution);
523
+ const activeExecutionInactiveResponseSchema = z.object({
524
+ success: z.literal(true),
525
+ enabled: z.boolean().optional(),
526
+ active: z.literal(false),
527
+ preserved: z.boolean().optional(),
528
+ }).strict().superRefine(rejectInactiveExecution);
529
+ const activeExecutionEnvelopeSchema = getExecutionResponseObjectSchema.extend({
530
+ enabled: z.boolean().optional(),
531
+ active: z.boolean().optional(),
532
+ preserved: z.boolean().optional(),
533
+ }).strict().superRefine(rejectInactiveExecution);
534
+ const activeExecutionResponseSchema = z.union([
535
+ activeExecutionInactiveResponseSchema,
536
+ activeExecutionOrdinaryResponseSchema,
537
+ activeExecutionAccessReviewResponseSchema,
538
+ ]);
382
539
  const accessDefermentSchema = z.object({
383
540
  id: z.number().int().min(1),
384
541
  jobId: z.number().int().min(1),
@@ -392,7 +549,7 @@ const clearedAccessDefermentSchema = accessDefermentSchema.extend({
392
549
  }).strict();
393
550
  const accessDefermentListResponseSchema = z.object({
394
551
  success: z.literal(true),
395
- deferments: z.array(accessDefermentSchema).max(APPLY_EXECUTION_MAX_TARGET),
552
+ deferments: z.array(accessDefermentSchema).max(APPLY_ACCESS_DEFERMENT_MAX_ACTIVE),
396
553
  }).strict().refine(({ deferments }) => (
397
554
  new Set(deferments.map(({ id }) => id)).size === deferments.length
398
555
  ), { message: 'deferment IDs must be unique' });
@@ -634,7 +791,7 @@ const startApplyRunSchema = z.object({
634
791
  }
635
792
  });
636
793
 
637
- const APPLY_RELIABILITY_PROMPT = 'Protocol 3.7 / skill 4.8.0 reliability gate: recover active work first. Consume the exact proposedWave and frozen accessKnowledge receipts before opening any browser. Rank OPEN ahead of neutral VARIES/UNKNOWN, then fresh ACCOUNT WALL; never auto-select an active user deferment. Historical OPEN never authorizes fill_form; freshLiveProbeRequired remains true. When nextAction is access_review, display the exact accessProposal and approvalHash, then obtain explicit approval for those exact jobIds and call the hash-bound approval continuation; if access is deferred, wait for probe, clear-deferment, stop, or expiry. Do not open a browser or report the queue exhausted. Retain the latest explicit target as hard, prove genuine applicant fields before counting access, obtain approval for the exact accessible jobs before form mutation, fill all deterministic fields before one true-gap question packet, and validate each value-free phase checkpoint. Every checkpoint action must use its canonical continuationAllowed value; review/manual_submit, captcha/at_submit, trust/origin_mismatch, and observability/unverifiable_state require false. Fail closed on a rejected checkpoint. After full local context loss, list recoverable executions, show only stable job identity, obtain explicit confirmation of the exact candidate set, and call exact-member recovery without substitutions. An active personal deferment blocks exact recovery until cleared. Treat recovered tab presence, form state, and mutation authority as three separate facts; reacquire a fresh browser binding and inspection epoch before mutation. Before resolving broad submission statements, list active review handoffs for the execution; use only an explicit receipt or the sole returned active receipt, classify every member as detected, user_confirmed, unresolved, or contradictory, and claim that exact handoff before writing outcomes. Use provider-specific positive success evidence; an unchanged URL or title is never negative evidence. Validate an exact expected browser keep set locally before finalization. For resume uploads, negotiate the browser surface capabilities, identify the semantic control, arm the chooser before clicking, attach the immediately verified file, prove the user-facing filename committed, and recheck parser-modified fields. Use compact snapshots and server-provided mutability. Preserve user-edited and unknown non-empty fields. Never reopen parked work without explicit user resumption. Never click Submit.';
794
+ const APPLY_RELIABILITY_PROMPT = 'Protocol 3.7 / skill 4.8.0 reliability gate: recover active work first. Consume the exact proposedWave and frozen accessKnowledge receipts before opening any browser. Rank OPEN ahead of neutral VARIES/UNKNOWN, then fresh ACCOUNT WALL; never auto-select an active user deferment. Historical OPEN never authorizes fill_form; freshLiveProbeRequired remains true. When nextAction is access_review, including ordinary OPEN or neutral proposals, display the exact accessProposal and accessProposal.approvalHash. If the proposal has nonempty members, obtain explicit approval for those exact ordered jobIds and call the hash-bound approval continuation. If the proposal has zero members because all remaining candidates are deferred or exact recovery is blocked by a user deferment, surface the deferred count and stable deferment IDs, offer clear-deferment only for explicitly returned IDs, stop, or expiry, and never send an empty approval. List or create deferments only at job, company, or provider scope through the jobId-bound tools; provider scope applies across companies until explicitly cleared. Do not open a browser or report the queue exhausted. Retain the latest explicit target as hard, prove genuine applicant fields before counting access, obtain approval for the exact accessible jobs before form mutation, fill all deterministic fields before one true-gap question packet, and validate each value-free phase checkpoint. Every checkpoint action must use its canonical continuationAllowed value; review/manual_submit, captcha/at_submit, trust/origin_mismatch, and observability/unverifiable_state require false. Fail closed on a rejected checkpoint. After full local context loss, list recoverable executions, show only stable job identity, obtain explicit confirmation of the exact candidate set, and call exact-member recovery without substitutions. An active personal deferment blocks exact recovery until cleared. Treat recovered tab presence, form state, and mutation authority as three separate facts; reacquire a fresh browser binding and inspection epoch before mutation. Before resolving broad submission statements, list active review handoffs for the execution; use only an explicit receipt or the sole returned active receipt, classify every member as detected, user_confirmed, unresolved, or contradictory, and claim that exact handoff before writing outcomes. Use provider-specific positive success evidence; an unchanged URL or title is never negative evidence. Validate an exact expected browser keep set locally before finalization. For resume uploads, negotiate the browser surface capabilities, identify the semantic control, arm the chooser before clicking, attach the immediately verified file, prove the user-facing filename committed, and recheck parser-modified fields. Use compact snapshots and server-provided mutability. Preserve user-edited and unknown non-empty fields. Never reopen parked work without explicit user resumption. Never click Submit.';
638
795
 
639
796
  function registerApplyTools(
640
797
  server,
@@ -679,17 +836,28 @@ function registerApplyTools(
679
836
  }
680
837
  const pendingAccessProposalByExecution = new Map();
681
838
  const replayableAccessApprovalByExecution = new Map();
839
+ const MAX_ACCESS_PROPOSAL_BINDINGS = 64;
840
+ function setBoundedAccessProposalBinding(bindings, executionId, value) {
841
+ bindings.delete(executionId);
842
+ bindings.set(executionId, value);
843
+ while (bindings.size > MAX_ACCESS_PROPOSAL_BINDINGS) {
844
+ bindings.delete(bindings.keys().next().value);
845
+ }
846
+ }
682
847
  function rememberAccessProposal(executionId, revision, browserSurface, response) {
683
848
  if (response.progress?.nextAction !== 'access_review') {
684
849
  pendingAccessProposalByExecution.delete(executionId);
685
850
  return response;
686
851
  }
687
- pendingAccessProposalByExecution.set(executionId, {
852
+ setBoundedAccessProposalBinding(pendingAccessProposalByExecution, executionId, {
688
853
  revision,
689
854
  browserSurface,
690
855
  approvalHash: response.accessProposal.approvalHash,
691
856
  jobIds: response.accessProposal.members.map(({ jobId }) => jobId),
692
857
  });
858
+ for (const deferment of response.accessProposal.blockedJobDeferments ?? []) {
859
+ discoveredDefermentIds.add(deferment.defermentId);
860
+ }
693
861
  return response;
694
862
  }
695
863
  server.tool(
@@ -812,11 +980,38 @@ function registerApplyTools(
812
980
  const rawResponse = await applyControlRequest(
813
981
  'POST', '/api/jobscout/apply/executions', body, idempotencyKey,
814
982
  );
815
- const response = rawResponse?.proposedWave !== undefined || rawResponse?.accessProposal !== undefined
816
- ? startExecutionAccessReviewResponseSchema.parse(rawResponse)
817
- : rawResponse;
983
+ let response = startExecutionResponseSchema.parse(rawResponse);
984
+ // An idempotent start replay may return the existing pending execution
985
+ // and its access_review progress without inlining the proposal. Hydrate
986
+ // the same strict detail response used by active recovery before the
987
+ // start result is exposed or cached.
988
+ if (response.progress.nextAction === 'access_review'
989
+ && response.proposedWave === undefined) {
990
+ const hydrated = validateGetExecutionResponse(await applyControlRequest(
991
+ 'GET', `/api/jobscout/apply/executions/${response.execution.id}`,
992
+ ));
993
+ if (hydrated.execution?.id !== response.execution.id
994
+ || hydrated.execution.revision !== response.execution.revision
995
+ || hydrated.progress.nextAction !== 'access_review'
996
+ || hydrated.proposedWave === undefined) {
997
+ throw new Error('Trackly start access review did not include its proposal.');
998
+ }
999
+ response = startExecutionAccessReviewResponseSchema.parse({
1000
+ ...response,
1001
+ execution: hydrated.execution,
1002
+ progress: hydrated.progress,
1003
+ proposedWave: hydrated.proposedWave,
1004
+ accessProposal: hydrated.accessProposal,
1005
+ });
1006
+ }
818
1007
  if (response?.proposedWave !== undefined && response?.execution?.id !== undefined) {
819
1008
  rememberAccessProposal(response.execution.id, response.execution.revision, null, response);
1009
+ } else if (response?.execution?.id !== undefined) {
1010
+ // A replay may legitimately return ordinary progress after the
1011
+ // access-review wave was consumed. Do not retain a prior approval
1012
+ // receipt that could authorize a later request for the same execution.
1013
+ pendingAccessProposalByExecution.delete(response.execution.id);
1014
+ replayableAccessApprovalByExecution.delete(response.execution.id);
820
1015
  }
821
1016
  return response;
822
1017
  }, 'Failed to start apply execution')
@@ -828,13 +1023,44 @@ function registerApplyTools(
828
1023
  {},
829
1024
  wrapTool(async () => {
830
1025
  const rawResponse = await applyControlRequest('GET', '/api/jobscout/apply/executions/active');
831
- const response = rawResponse?.execution !== undefined || rawResponse?.progress !== undefined
832
- ? (rawResponse?.active !== undefined || rawResponse?.preserved !== undefined
833
- ? activeExecutionAccessReviewResponseSchema.parse(rawResponse)
834
- : validateGetExecutionResponse(rawResponse))
835
- : rawResponse;
1026
+ const pendingProposal = rawResponse?.execution !== undefined
1027
+ && rawResponse?.progress?.nextAction === 'access_review'
1028
+ && rawResponse?.proposedWave === undefined;
1029
+ let response = pendingProposal
1030
+ ? activeExecutionEnvelopeSchema.parse(rawResponse)
1031
+ : activeExecutionResponseSchema.parse(rawResponse);
1032
+ // The deployed active endpoint reports the authoritative execution and
1033
+ // progress but does not inline a pending access proposal. Hydrate that
1034
+ // proposal from the strict execution endpoint before exposing an
1035
+ // access_review response, otherwise restart/recovery would stall on a
1036
+ // false missing-proposal error.
1037
+ if (
1038
+ response.progress?.nextAction === 'access_review'
1039
+ && response.execution?.id !== undefined
1040
+ && response.proposedWave === undefined
1041
+ ) {
1042
+ const hydrated = validateGetExecutionResponse(await applyControlRequest(
1043
+ 'GET', `/api/jobscout/apply/executions/${response.execution.id}`,
1044
+ ));
1045
+ if (hydrated.execution?.id !== response.execution.id
1046
+ || hydrated.execution.revision !== response.execution.revision
1047
+ || hydrated.progress.nextAction !== 'access_review'
1048
+ || hydrated.proposedWave === undefined) {
1049
+ throw new Error('Trackly active execution access review did not include its proposal.');
1050
+ }
1051
+ response = activeExecutionResponseSchema.parse({
1052
+ ...response,
1053
+ ...hydrated,
1054
+ enabled: response.enabled,
1055
+ active: response.active,
1056
+ preserved: response.preserved,
1057
+ });
1058
+ }
836
1059
  if (response?.execution?.id && response.proposedWave !== undefined) {
837
1060
  rememberAccessProposal(response.execution.id, response.execution.revision, null, response);
1061
+ } else if (response?.execution?.id) {
1062
+ pendingAccessProposalByExecution.delete(response.execution.id);
1063
+ replayableAccessApprovalByExecution.delete(response.execution.id);
838
1064
  }
839
1065
  return response;
840
1066
  }, 'Failed to recover active apply execution')
@@ -853,6 +1079,7 @@ function registerApplyTools(
853
1079
  }
854
1080
  if (response.proposedWave === undefined) {
855
1081
  pendingAccessProposalByExecution.delete(executionId);
1082
+ replayableAccessApprovalByExecution.delete(executionId);
856
1083
  return response;
857
1084
  }
858
1085
  return rememberAccessProposal(executionId, response.execution.revision, null, response);
@@ -1049,7 +1276,7 @@ function registerApplyTools(
1049
1276
 
1050
1277
  server.tool(
1051
1278
  'trackly_advance_apply_execution',
1052
- 'Advance an execution transactionally for the current browser surface. Returns the immutable proposedWave with frozen accessKnowledge receipts and never opens a browser. Same-key replay returns the same members, order, and rationale. Optional hash-bound accessReviewApproval probes exact deferred job IDs only after personal deferments are cleared.',
1279
+ 'Advance an execution transactionally for the current browser surface. Returns the immutable proposedWave with frozen accessKnowledge receipts and never opens a browser. Same-key replay returns the same members, order, and rationale. Optional hash-bound accessReviewApproval probes the exact proposed job IDs only after personal deferments are cleared.',
1053
1280
  {
1054
1281
  executionId: z.number().int().min(1),
1055
1282
  expectedRevision: z.number().int().min(1),
@@ -1090,13 +1317,16 @@ function registerApplyTools(
1090
1317
  }
1091
1318
  if (body.accessReviewApproval) {
1092
1319
  pendingAccessProposalByExecution.delete(executionId);
1093
- replayableAccessApprovalByExecution.set(executionId, {
1320
+ setBoundedAccessProposalBinding(replayableAccessApprovalByExecution, executionId, {
1094
1321
  revision: body.expectedRevision,
1095
1322
  browserSurface: body.browserSurface,
1096
1323
  approvalHash: body.accessReviewApproval.approvalHash,
1097
1324
  jobIds: [...body.accessReviewApproval.jobIds],
1098
1325
  idempotencyKey,
1099
1326
  });
1327
+ if (response.proposedWave !== undefined && response.progress?.nextAction === 'access_review') {
1328
+ rememberAccessProposal(executionId, response.revision, body.browserSurface, response);
1329
+ }
1100
1330
  } else if (response.proposedWave !== undefined) {
1101
1331
  rememberAccessProposal(executionId, response.revision, body.browserSurface, response);
1102
1332
  } else {
@@ -1158,7 +1388,7 @@ function registerApplyTools(
1158
1388
 
1159
1389
  server.tool(
1160
1390
  'trackly_defer_apply_access',
1161
- 'Persist an explicit user deferment for one Trackly job or its derived company or provider scope. Provider scope blocks that provider across companies, proposal approval, recovery, and replay until cleared. The server derives company, provider, tenant, origin, and route from jobId; never submit a provider name, URL, or free text.',
1391
+ 'Persist an explicit user deferment for one Trackly job or its derived company or provider scope. Provider scope applies across companies until explicitly cleared. The server derives company, provider, tenant, origin, and route from jobId; never submit a provider name, URL, or free text.',
1162
1392
  {
1163
1393
  jobId: z.number().int().min(1),
1164
1394
  scope: z.enum(APPLY_ACCESS_DEFERMENT_SCOPES),
@@ -1181,7 +1411,7 @@ function registerApplyTools(
1181
1411
 
1182
1412
  server.tool(
1183
1413
  'trackly_clear_apply_access_deferment',
1184
- 'Clear one explicit user deferment previously listed or created in this session. An exact same-session retry remains idempotent, including for provider-wide user policy.',
1414
+ 'Clear one explicit user deferment previously listed or created in this session. An exact same-session retry remains idempotent.',
1185
1415
  {
1186
1416
  defermentId: z.number().int().min(1),
1187
1417
  idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
@@ -1206,6 +1436,7 @@ function registerApplyTools(
1206
1436
  throw new Error('Access deferment response does not match the requested deferment id.');
1207
1437
  }
1208
1438
  rememberClearedDefermentReplay(defermentId, idempotencyKey);
1439
+ discoveredDefermentIds.delete(defermentId);
1209
1440
  return response;
1210
1441
  }, 'Failed to clear apply access deferment')
1211
1442
  );
@@ -1745,7 +1976,7 @@ function registerApplyTools(
1745
1976
  role: 'user',
1746
1977
  content: {
1747
1978
  type: 'text',
1748
- text: 'Protocol 3.7.0 reliability gate for new work: require MCP contract 3.8.1 and skill 4.8.0. Consume the exact proposedWave with frozen accessKnowledge before opening any browser; same-key advance replay returns identical member IDs, order, and rationale. When nextAction is access_review, display the deferred proposal and do not report exhaustion. Defer or clear only through jobId-scoped tools; provider scope persists server-derived provider exclusions across companies, approval, recovery, and replay. Never submit provider names, URLs, or raw chat. After complete local context loss, list bounded recovery candidates, obtain explicit confirmation of the exact set, and recover only that set. An active personal deferment blocks exact recovery until cleared. Treat tab recovery, form-state recovery, and mutation authority as independent. List active handoff receipts for the execution before resolving grouped submission statements; use the named receipt or the sole returned active receipt, classify every member, and claim that receipt before recording outcomes. Validate tab keep sets and resume upload stages locally. Never send raw browser values or click Submit.',
1979
+ text: 'Protocol 3.7.0 reliability gate for new work: require MCP contract 3.8.1 and skill 4.8.0. Consume the exact proposedWave with frozen accessKnowledge before opening any browser; same-key advance replay returns identical member IDs, order, and rationale. When nextAction is access_review, including ordinary OPEN or neutral proposals, display the exact access-review receipt and do not report exhaustion. For a nonempty proposal, obtain explicit approval for the unchanged ordered job IDs and server approval hash before probing. For an all-deferred or recovery-blocked proposal with zero members, show the deferred count and stable job/company/provider deferment IDs, offer clear-deferment only for explicitly returned IDs, stop, or expiry, and never send an empty approval. Defer or clear only through jobId-scoped job, company, or provider tools; provider scope applies across companies until explicitly cleared. Never submit provider names, URLs, or raw chat. After complete local context loss, list bounded recovery candidates, obtain explicit confirmation of the exact set, and recover only that set. An active personal deferment blocks exact recovery until cleared. Treat tab recovery, form-state recovery, and mutation authority as independent. List active handoff receipts for the execution before resolving grouped submission statements; use the named receipt or the sole returned active receipt, classify every member, and claim that receipt before recording outcomes. Validate tab keep sets and resume upload stages locally. Never send raw browser values or click Submit.',
1749
1980
  },
1750
1981
  }, {
1751
1982
  role: 'user',
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "trackly-cli",
9
- "version": "0.18.0",
9
+ "version": "0.18.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "1.30.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "mcpName": "io.github.trackly-app/trackly",
5
5
  "description": "AI job search CLI + hosted MCP server with OAuth. 170K+ jobs, 3,800+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
6
6
  "bin": {
package/server.json CHANGED
@@ -17,12 +17,12 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.18.0",
20
+ "version": "0.18.1",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.18.0",
25
+ "version": "0.18.1",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {
@@ -59,8 +59,8 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
59
59
  - When a visible form asks an office-scoped profile question and the exact employer office is known, request that field only through the matching member's `officeProjections` entry using the canonical `companyId:office-identity` scope. Consume only the returned `memberOfficeProfiles` entry for that same member and exact office; never move an office answer into the global profile map or reuse it for another member, employer, or office. A recovered member may reuse the answer only when Trackly proves its frozen company identity. If a legacy snapshot cannot prove that identity, preserve the form and treat the answer as unknown instead of refilling from chat or current mutable job metadata.
60
60
  - Recover every entry in `execution.unresolvedWaves` in ascending `waveOrder`; `currentWave` identifies only the latest scheduling wave, not the complete recovery set.
61
61
  - Immediately consume the start response's authoritative `progress` and `nextAction`; do not issue a speculative advance first.
62
- - When that authoritative `nextAction` requests another wave, call `trackly_advance_apply_execution` with the actual current `browserSurface`, latest execution revision, and a fresh idempotency key. Pause on every returned immutable `proposedWave`, including ordinary OPEN or neutral proposals. Before asking for approval, show each server-frozen member in `memberPosition` order with its job ID, company, title, provider, requisition URL, and value-free scheduling reason; require the single projected `proposedWave` identity to match the corresponding `accessProposal.members[]` identity exactly. Missing, noncontiguous, or mismatched identity blocks approval; never substitute current search results, chat, browser state, or a later job lookup for the frozen proposal. Obtain the user's explicit approval of that exact ordered set, then call `trackly_advance_apply_execution` again with the unchanged `accessProposal.members[].jobId` order, its server-provided `accessProposal.approvalHash`, returned revision, same browser surface, and a fresh idempotency key. After `createdWave: true`, never reapprove that wave; ask only for a later `nextAction: access_review` where no wave was created. Never substitute a synthetic hash or infer approval from an earlier request to apply generally. Never describe a proposed job as accessible until the current live probe proves applicant fields. `freshLiveProbeRequired` remains true for every classification; curated OPEN never changes `allowedOperations` to `fill_form`.
63
- - Progress may include `availableCandidateCount` and `deferredCandidateCount`. When `nextAction` is `access_review`, display the bounded deferred proposal, do not open a browser, and do not report the queue as exhausted. Offer probe, clear-deferment, stop, or expiry. Probe exact deferred job IDs only with the server-provided hash-bound `accessReviewApproval` after any active personal deferment for those jobs is cleared. List deferments with `trackly_list_apply_access_deferments`, create one with `trackly_defer_apply_access` from `jobId` and scope `job`, `company`, or `provider`, and clear one with `trackly_clear_apply_access_deferment` using a discovered deferment id. Provider scope is the approved global access-policy boundary: the backend derives the provider from the stable job anchor and blocks that provider across companies, proposal approval, recovery, and replay until explicitly cleared. Persist a provider exclusion only after the current authenticated user explicitly requests that policy; never infer a Workday, Eightfold, or other provider preference from this shared skill. Never submit a URL, provider name, or free-text identity.
62
+ - When that authoritative `nextAction` requests another wave, call `trackly_advance_apply_execution` with the actual current `browserSurface`, latest execution revision, and a fresh idempotency key. Pause on every returned immutable `proposedWave`, including ordinary OPEN or neutral proposals. The local response uses a compact `proposedWave` projection with each member's `jobId` and `accessKnowledge`, alongside the rich `accessProposal` receipt with contiguous `memberPosition`, rationale, and approval hash. Before asking for approval, show each rich server-frozen member in exact `memberPosition` order with its job ID and value-free scheduling reason; require the compact projection's ordered job IDs and access knowledge to match `accessProposal.members[]` exactly. Missing, noncontiguous, or mismatched identity blocks approval; never substitute current search results, chat, browser state, or a later job lookup for the frozen proposal. If the proposal has no members because all remaining candidates are deferred or exact recovery is blocked by a user deferment, do not submit an empty approval: show the returned deferred count and stable job/scope/deferment IDs, then either clear only deferment IDs the user explicitly selects and request a fresh proposal, or offer stop/expiry. If a legacy receipt omits the mapping, stop or wait for expiry and request a fresh proposal rather than guessing an ID. Otherwise obtain the user's explicit approval of that exact ordered set, then call `trackly_advance_apply_execution` again with the unchanged `accessProposal.members[].jobId` order, its server-provided `accessProposal.approvalHash`, returned revision, same browser surface, and a fresh idempotency key. After `createdWave: true`, never reapprove that wave; ask only for a later `nextAction: access_review` where no wave was created. Never substitute a synthetic hash or infer approval from an earlier request to apply generally. Never describe a proposed job as accessible until the current live probe proves applicant fields. `freshLiveProbeRequired` remains true for every classification; curated OPEN never changes `allowedOperations` to `fill_form`.
63
+ - Progress may include `availableCandidateCount` and `deferredCandidateCount`. When `nextAction` is `access_review`, display the bounded access-review proposal, including ordinary OPEN or neutral members, do not open a browser, and do not report the queue as exhausted. For a zero-member proposal declared `all_candidates_user_deferred` or `recovery_blocked_by_user_deferment`, offer clear-deferment only for explicitly returned deferment IDs; the recovery-blocked case may still report available candidates, so do not call it queue exhaustion. If a legacy receipt omits the mapping, stop or wait for expiry and request a fresh proposal rather than guessing an ID. Never send an empty approval. For a nonempty proposal, probe exact proposed job IDs only with the server-provided hash-bound `accessReviewApproval` after any active personal deferment for those jobs is cleared. List deferments with `trackly_list_apply_access_deferments`, create one with `trackly_defer_apply_access` from `jobId` and scope `job`, `company`, or `provider`, and clear one with `trackly_clear_apply_access_deferment` using a discovered deferment id. Provider scope is the approved global access-policy boundary: the backend derives the provider from the stable job anchor and applies it across companies until explicitly cleared. Never submit a URL, provider name, or free-text identity.
64
64
  - Recover an existing execution even when `batchOrchestration.accessibleExecution.enabled` is false. When disabled and an execution is active, recover it read-only; never start, advance, or record dispositions. Use only get or stop operations.
65
65
  - If the active execution target differs from the user's requested target, explain the mismatch and require explicit confirmation. Only then stop it with reason `target_changed`, verify the exact execution reached `stopped` or `closed` from the stop response or `trackly_get_apply_execution`, and refetch until the active-execution response says `active: false`. A terminal execution appears with `preserved: true` only when reconciliation evidence remains, so its absence is not a blocker after the exact execution is terminal. Start the replacement target without continuing the stopped wave.
66
66
  - When the user asks to stop, call `trackly_stop_apply_execution` with reason `user_requested`, verify the exact execution reached `stopped` or `closed` from the stop response or `trackly_get_apply_execution`, refetch until `active: false`, and never continue its unresolved waves. If the response also says `preserved: true`, keep that record read-only for reconciliation; do not require preservation when no unresolved evidence remains.
@@ -83,30 +83,40 @@ probe while Greenhouse or other `OPEN`/neutral alternatives exist. Eightfold
83
83
  starts as `ACCOUNT WALL`, is independently authentication-gated, and must not be
84
84
  opened for a browser probe under the same condition. Static safety exclusions
85
85
  always override `OPEN`.
86
- An active job, company, or provider deferment is an unconditional no-browser
87
- rule, regardless of whether another accessible candidate remains.
86
+ An active job, company, or provider deferment is an unconditional no-browser rule,
87
+ regardless of whether another accessible candidate remains.
88
88
 
89
89
  Read `progress.availableCandidateCount`, `progress.deferredCandidateCount`, and
90
- `nextAction`. When `nextAction` is `access_review`, return the bounded deferred
91
- proposal, open nothing, and do not report the queue as exhausted. Probe those
92
- exact job IDs only with hash-bound `accessReviewApproval` after clearing any
93
- active personal deferment. List, defer, or clear deferments only through
94
- `trackly_list_apply_access_deferments`, `trackly_defer_apply_access`, and
95
- `trackly_clear_apply_access_deferment` using a Trackly `jobId`; never submit a
96
- URL, provider name, or raw chat. Scope `provider` is the approved global
97
- user-policy boundary: the server derives that identity and blocks matching
98
- candidates across companies, proposal approval, recovery, and replay. Create a
99
- provider deferment only after the current authenticated user explicitly requests
100
- that persistent provider policy; never infer it from this shared skill.
90
+ `nextAction`. When `nextAction` is `access_review`, return the bounded
91
+ access-review proposal, including ordinary OPEN or neutral members, open
92
+ nothing, and do not report the queue as exhausted. If the proposal has no
93
+ members because all remaining candidates are deferred, or because exact
94
+ recovery is blocked by a user deferment
95
+ (`recovery_blocked_by_user_deferment`), show its deferred count and matching
96
+ stable job/scope/deferment IDs, then offer clear-deferment only for IDs the user
97
+ explicitly selects and the server returns. Recovery-blocked proposals may
98
+ still report available candidates; they are not queue exhaustion. If a legacy
99
+ receipt omits that mapping, stop or expiry rather than guessing an ID. Never
100
+ send an empty approval.
101
+ Clear any active personal deferment before probing. For a nonempty proposal,
102
+ probe those exact job IDs only with hash-bound `accessReviewApproval` after
103
+ clearing that deferment. List or create deferments only through
104
+ `trackly_list_apply_access_deferments` and `trackly_defer_apply_access` using a
105
+ Trackly `jobId` and scope `job`, `company`, or `provider`. Provider scope is a
106
+ global policy boundary derived from that stable job anchor and applies across
107
+ companies until explicitly cleared. Clear only through
108
+ `trackly_clear_apply_access_deferment` using the exact user-selected
109
+ `defermentId` returned by deferment discovery or creation. Never submit a URL,
110
+ provider name, or raw chat.
101
111
 
102
112
  Pause after every `proposedWave`. Same-key advance replay returns the same
103
113
  member IDs, order, and rationale. Browser probing requires explicit wave
104
- approval, including when every proposed job is OPEN or neutral. Show every
105
- server-frozen member in exact `memberPosition` order with its job ID, company,
106
- title, provider, requisition URL, and a short value-free reason such as
107
- “Greenhouse default: OPEN, audited 6 days ago; fresh probe still required.”
108
- Require each simple `proposedWave` identity to equal the corresponding rich
109
- `accessProposal.members[]` identity. Missing, noncontiguous, or mismatched
114
+ approval, including when every proposed job is OPEN or neutral. The local
115
+ projection contains each member's `jobId` and value-free `accessKnowledge`;
116
+ the rich `accessProposal.members[]` receipt contains the contiguous position,
117
+ rationale, and approval metadata. Show those exact job IDs and scheduling
118
+ reasons in order and require the compact projection's ordered IDs and access
119
+ knowledge to match the rich receipt. Missing, noncontiguous, or mismatched
110
120
  identity blocks approval; never substitute chat, browser state, search results,
111
121
  or a later mutable job lookup. Never describe a proposed job as accessible until the current
112
122
  probe proves it. Bind approval to the unchanged