job-application-agent 3.4.2 → 3.5.0

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/README.md CHANGED
@@ -130,6 +130,8 @@ The bundled CLI handles private profile storage, résumé import, scoring, dupli
130
130
 
131
131
  Discovery combines the reviewed [`SOURCES.json`](job-application-agent/references/SOURCES.json) catalog with an anonymous community registry. Every confirmed application automatically contributes its canonical public job URL, company, role, application channel, and provider; prior confirmed ledger entries backfill during later commands after a one-command disclosure grace period. Jobs, repeatable boards, and feeds are logged pending and become visible in the public dashboard or CLI only after maintainer review. Disable both forms of community sharing independently from analytics with `sources sharing disable`.
132
132
 
133
+ New rounds derive reviewed and qualified totals from private per-lead records, including rejection reasons and revision history. Delivery reconciliation subtracts verified failed emails from effective application totals without erasing history; email access is optional, and sent email without acknowledgement remains labelled receipt unknown. See [accounting](job-application-agent/references/ACCOUNTING.md).
134
+
133
135
  Rounds require recorded attempts across at least three distinct discovery sources, including a successful search, and source attribution for confirmed submissions. If more than 60% of submissions come from one source, the agent must explain the concentration. Blockers and empty results are reported; fit requirements never change to meet a source quota. See [round coverage](job-application-agent/references/RUNS.md#discovery-coverage).
134
136
 
135
137
  ## 🔐 Privacy
@@ -28,12 +28,16 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
28
28
 
29
29
  Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answers, browser session data, or inferred candidate facts.
30
30
 
31
+ ## Accounting
32
+
33
+ Read [references/ACCOUNTING.md](references/ACCOUNTING.md) before recording delivery evidence, recovery attempts, or per-lead discovery. For new rounds, record each lead with `round lead --stdin` and derive source totals from those records. Email access is optional: visible browser success counts, verified email sends count with receipt unknown, and matched final delivery failures correct effective totals. Preserve historical events and use explicit corrections for conflicts.
34
+
31
35
  ## Discover and assess
32
36
 
33
37
  Read [references/SOURCES.md](references/SOURCES.md) before the first discovery pass in a workflow.
34
38
 
35
39
  1. Run `sources jobs` for recently confirmed direct job links and `sources list` (optionally filtered) for the highest-signal packaged and maintainer-reviewed discovery sources. Resolve every lead to the direct employer or ATS page.
36
- For each round, select at least three distinct relevant discovery sources before applying. Search across them before working deeply through one feed; include alternatives to the previous round's dominant source. Record each actual search, including zero suitable results, or an observed access blocker with `round source --stdin`. Two YC views count as one network; recruiter inboxes and user-supplied links supplement discovery but do not satisfy the three-source minimum. Do not claim that listing the catalog means a board was searched. Keep a blocked source in the report and continue to accessible alternatives.
40
+ For each round, select at least three distinct relevant discovery sources before applying. Search across them before working deeply through one feed; include alternatives to the previous round's dominant source. Record individual reviewed leads first, then each actual search, including zero suitable results, or an observed access blocker with `round source --stdin`. Two YC views count as one network; recruiter inboxes and user-supplied links supplement discovery but do not satisfy the three-source minimum. Do not claim that listing the catalog means a board was searched. Keep a blocked source in the report and continue to accessible alternatives.
37
41
  2. Attribute the lead with coarse `discoverySource`, stable packaged or community `discoverySourceId` when known, and independent `applicationChannel`. Treat a one-off user link as `user-supplied`. Whenever a user or agent discovers a repeatable public board, feed, directory, or careers index that is not already listed, run `sources suggest --stdin`; the CLI contributes its sanitized metadata by default unless community sharing has been disabled.
38
42
  3. Verify the application channel immediately before assessment. Mark it `active`, `closed`, or `unclear`.
39
43
  4. Classify eligibility only after checking residence, location, work authorization, sponsorship, schedule, and employment type.
@@ -76,7 +80,8 @@ Check `round status` after the initial discovery pass and before submitting. Pre
76
80
  - Record outcomes with `ledger outcome --stdin`. Use structured rejection reasons and mark each as `explicit` or `inferred`. Do not treat an inference as a candidate fact.
77
81
  - After an interview, optionally record `interviewQuality` (`promising`, `viable`, `weak`, or `dead`) and a bounded `failurePoint`. Keep free-form interview notes private.
78
82
  - Rely on idempotent outcome recording; identical events do not append rows or emit duplicate telemetry.
79
- - Run `ledger review` for canonical unique submissions, duplicate-row counts, mature applications, reasons, interview-quality/failure-point counts, source and fit-score learning segments, and mature-cohort conversions.
83
+ - Audit matched delivery failures with authorized email tools when available; otherwise report delivery not audited and continue. Keep delivery failures separate from hiring rejections.
84
+ - Run `ledger review` for effective canonical unique submissions, duplicate-row counts, mature applications, reasons, interview-quality/failure-point counts, source and fit-score learning segments, and mature-cohort conversions.
80
85
  - Review submission hygiene after each ten newly acknowledged unique submissions.
81
86
  - Review outcome effectiveness only after at least 20 newly acknowledged applications have aged ten business days.
82
87
  - Generate proposals only. Change targeting, profile facts, resume claims, scoring thresholds, or answer guidance only with candidate approval.
@@ -98,6 +103,10 @@ node scripts/job-application.mjs profile field <allowed-field>
98
103
  node scripts/job-application.mjs resume import <google-doc-url-or-local-pdf>
99
104
  node scripts/job-application.mjs resume path
100
105
  node scripts/job-application.mjs score --stdin
106
+ node scripts/job-application.mjs ledger delivery|retry --stdin
107
+ node scripts/job-application.mjs ledger deliveries [application-id]
108
+ node scripts/job-application.mjs round lead --stdin
109
+ node scripts/job-application.mjs round leads [round-id]
101
110
  node scripts/job-application.mjs ledger check --stdin
102
111
  node scripts/job-application.mjs ledger add --stdin
103
112
  node scripts/job-application.mjs ledger outcome --stdin
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "capabilities": [
3
- "cloud-state-v2"
3
+ "cloud-state-v2",
4
+ "application-accounting-v1"
4
5
  ]
5
6
  }
@@ -0,0 +1,104 @@
1
+ # Delivery and discovery accounting
2
+
3
+ ## Evidence and effective totals
4
+
5
+ Browser applications count after visible ATS success. They do not require email access. Email-only applications count after a verified send to an employer-published recruiting address; describe them as **sent, receipt unknown**, not as confirmed receipt. Use authorized email tools when available before completing a round and during outcome reviews. Without access, report "delivery not audited" and continue. The CLI does not connect to Gmail or infer mailbox activity.
6
+
7
+ Match a final delivery failure to the actual application attempt using its sent message, recipient, timing and returned failure evidence. Temporary delays, unrelated bounces, and hiring rejections are not delivery failures. A failed notification cannot overturn independent browser confirmation. Never guess an alternate address or automatically resend an uncertain transmission.
8
+
9
+ `ledger review`, `round status`, and `round complete` share one effective-count projection. They expose `recordedSubmissionCount`, `effectiveSubmissionCount`, `failedDeliveryCount`, and `receiptUnknownEmailCount`. Existing `submittedTotal`/`confirmedCount` fields now reflect effective submissions, not proven email receipt. Conversion denominators exclude failed applications while historical outcomes remain visible. Review cadence and acknowledgement checkpoints use recorded canonical submissions and recorded mature applications, so a late failure does not postpone the next review. Public usage events remain historical activity metrics.
10
+
11
+ A late failure preserves the original completion event. `shortfallCount` and `needsRecovery` flag the deficit; no run starts or round reopens automatically. Review the derived attention queue before a later authorized recovery.
12
+
13
+ ## Record delivery evidence
14
+
15
+ ```text
16
+ node scripts/job-application.mjs ledger delivery --stdin
17
+ node scripts/job-application.mjs ledger deliveries [application-id]
18
+ ```
19
+
20
+ Example stdin (use your actual observation, never this synthetic evidence):
21
+
22
+ ```json
23
+ {
24
+ "id": "failure-message-123",
25
+ "applicationId": "application-123",
26
+ "attemptId": "initial:application-123",
27
+ "type": "delivery-failed",
28
+ "occurredAt": "2026-09-13T10:00:00Z",
29
+ "evidenceType": "final-delivery-failure",
30
+ "evidence": "Final recipient failure matched to the original sent recruiting message.",
31
+ "messageRef": "private-provider-message-reference"
32
+ }
33
+ ```
34
+
35
+ Original attempts are derived as `initial:<applicationId>` without rewriting application rows. Evidence types are `employer-acknowledgement`, `final-delivery-failure`, `browser-confirmation`, `sent-email`, `ambiguous`, and `delivery-delay`. Event types are `receipt-confirmed`, `delivery-failed`, and `correction`. For a correction, set `supersedes` to the mistaken event ID (or an array of conflicting IDs), and `status` to `receipt-confirmed`, `delivery-failed`, or `unknown`. Supply new evidence and a new ID. Unknown restores the original transmission status; it does not assert receipt.
36
+
37
+ Timestamps are required and normalized to UTC. IDs are optional deterministic content hashes; reuse the same input and observation timestamp when replaying an event. Reusing an ID with different content fails. Evidence is limited to 2,000 characters and message references to 500. Both stay private; omit raw email bodies, attachments, credentials, and unrelated personal data.
38
+
39
+ Contradictory or ambiguous evidence remains counted but enters derived attention and blocks recovery. Resolve it with an explicit correction, rather than `attention resolve`.
40
+
41
+ ## Replacement attempts
42
+
43
+ Recovery is allowed only when all attempts for the canonical application have verified failures and no conflicting evidence. Recheck employer eligibility and existing candidate authorization, then verify a published replacement channel within 24 hours before transmission. Preserve duplicate checks for every other application.
44
+
45
+ In cloud mode, acquire the existing application lease and call `cloud intent-prepare --stdin` with the original `applicationId`, replacement `canonicalUrl`, `leaseId`, and `retry: true`. Use the returned intent ID as the new attempt ID. An uncertain transmission uses `cloud intent-sent` and must not be resent. A later confirmation can reconcile that same intent. Eligibility is checked when the intent is prepared; matching verified transmission evidence remains recordable if a later receipt or correction changes the original failure evidence. The confirming host still needs its active lease.
46
+
47
+ After a verified replacement send, call `ledger retry --stdin`:
48
+
49
+ ```json
50
+ {
51
+ "id": "replacement-123",
52
+ "applicationId": "application-123",
53
+ "attemptId": "returned-cloud-intent-id",
54
+ "channel": "browser",
55
+ "url": "https://employer.example/careers/123",
56
+ "channelVerifiedAt": "2026-09-13T11:00:00Z",
57
+ "occurredAt": "2026-09-13T11:10:00Z",
58
+ "approval": "STANDING AUTHORIZATION",
59
+ "evidenceType": "browser-confirmation",
60
+ "evidence": "Visible ATS success for the replacement application.",
61
+ "cloudIntentId": "returned-cloud-intent-id",
62
+ "cloudLeaseId": "active-lease-id"
63
+ }
64
+ ```
65
+
66
+ For email use `channel: "email"` and `evidenceType: "sent-email"`; the URL identifies the employer-published channel page. With local storage, omit cloud fields and choose a new stable attempt ID. Recovery appends delivery history, never a second application, a second community contribution, or a duplicate submission telemetry event. Revoked authorization, expired leases, and unresolved intents still stop new transmissions.
67
+
68
+ ## Per-lead discovery
69
+
70
+ ```text
71
+ node scripts/job-application.mjs round lead --stdin
72
+ node scripts/job-application.mjs round leads [round-id]
73
+ ```
74
+
75
+ Record each reviewed job or careers page before reporting source totals:
76
+
77
+ ```json
78
+ {
79
+ "roundId": "round-123",
80
+ "sourceId": "indeed",
81
+ "url": "https://employer.example/jobs/123",
82
+ "company": "Example Employer",
83
+ "role": "Senior Engineer",
84
+ "employerJobId": "REQ-123",
85
+ "disposition": "qualified",
86
+ "observedAt": "2026-09-13T09:00:00Z",
87
+ "evidence": "Active posting meets the unchanged eligibility and evidence requirements.",
88
+ "applicationId": "application-123"
89
+ }
90
+ ```
91
+
92
+ Dispositions: `qualified`, `duplicate`, `no-relevant-opening`, `location-authorization-conflict`, `compensation-below-floor`, `seniority-mismatch`, `insufficient-must-have-coverage`, `closed-stale`, `blocked`. Role, employer job ID and application ID are optional during initial discovery; link the actual application ID before round completion. Use `supersedes` and a new event ID when revising an assessment or adding the application link.
93
+
94
+ A verified employer job ID merges requisition aliases. Without it, canonical URLs identify leads. Known tracking parameters are removed; unfamiliar parameters are preserved because they may identify distinct requisitions. Company punctuation and whitespace are normalized for verified requisition identity. A later verified ID can enrich the same URL. Repeated sightings do not increase counts; independent sources retain their sightings while unique totals deduplicate requisitions. Distinct requisitions remain separate. Conflicting revisions require an explicit superseding assessment and appear in attention. Existing-lead corrections remain possible after completion without reopening the round.
95
+
96
+ New rounds store `discoveryPolicyVersion: 2`. `round source` still records real searches and blockers; its optional `reviewedCount` and `qualifiedCount` are assertions against recorded leads. Empty searches may have zero leads with search evidence. New rounds cannot complete without qualified lead/application linkage and resolved assessment conflicts. Always record real submissions even if that audit is incomplete.
97
+
98
+ Existing rounds retain their historical policy and unsupported aggregates are marked `legacy-unverified`. Unfamiliar historical discovery records remain untouched. The three-source minimum and 60% concentration-explanation rule are unchanged.
99
+
100
+ ## Cloud compatibility and privacy
101
+
102
+ The optional private backend must advertise `application-accounting-v1`. Deploy the updated Worker before installing the new client on cloud-configured hosts. Missing capability produces an upgrade error. A previously verified backend permits cached research and durable observed-evidence recording during a network outage, but never a new transmission intent.
103
+
104
+ Delivery/discovery evidence stays in owner-only local streams or the authenticated private backend, and is included in private export and backup/restore. It never enters analytics or community sharing. Reconciliation uses stable event IDs and detects conflicts instead of selecting whichever record arrived last. Initial local-history import uses the existing `cloud reconcile` workflow; do not append new cloud recovery attempts through generic record APIs.
@@ -17,3 +17,5 @@ Browser sessions, Gmail credentials, passwords, verification codes, CAPTCHA resp
17
17
  Normal profile, résumé, ledger, outcome, round, attention, review, and friction commands automatically reconcile through the configured backend. `cloud export` creates an owner-only JSON archive. During a cloud outage, continue cached research and drafts but do not transmit a new application.
18
18
 
19
19
  For a shared Linux host where Codex and another agent need separate credentials, follow [`VPS_CLIENTS.md`](VPS_CLIENTS.md). Keep a single scheduler and rely on the D1 lease—not local process assumptions—to enforce the one-writer rule.
20
+
21
+ Accounting clients require the `application-accounting-v1` backend capability. Deploy Worker support before upgrading cloud-configured clients. Delivery and discovery streams participate in private reconciliation, export, and backup/restore. See [ACCOUNTING.md](ACCOUNTING.md).
@@ -21,6 +21,9 @@ Start input: `{ "requestedCount": 30 }`. Complete input: `{ "roundId": "round-..
21
21
 
22
22
  ## Discovery coverage
23
23
 
24
+ For new rounds, first record every reviewed lead with `round lead --stdin`; see [ACCOUNTING.md](ACCOUNTING.md) for fields, revisions, and delivery accounting. Source counts are derived from those lead records. The optional counts in the example below are assertions and require matching records. Existing rounds retain legacy, unverified aggregate reports.
25
+
26
+
24
27
  Before submitting, search at least three relevant independent sources from `sources list`, including alternatives to the last round's dominant source. Listing the catalog or browsing multiple jobs on one board is not source coverage. Record a report after each actual search or observed access blocker:
25
28
 
26
29
  ```json
@@ -56,6 +59,9 @@ Coverage reports emit bounded `source_checked` analytics automatically. Only all
56
59
 
57
60
  ## Submission accounting
58
61
 
62
+ Before completion, audit final email delivery failures through authorized email tools when available; otherwise report delivery not audited and continue. Verified email sends count with receipt unknown. Use `ledger delivery` to record matched failures, never `ledger outcome rejected`. Effective totals exclude failed attempts; completed rounds retain their completion record and expose a recovery shortfall. See [ACCOUNTING.md](ACCOUNTING.md) for safe linked replacement attempts.
63
+
64
+
59
65
  Count only unique applications with a visible employer/ATS confirmation or a verified sent recruiting email that were also added to the ledger with the same `roundId`. Filled forms, blockers, drafts, unsent email, and ambiguous confirmations never count. `round complete` rejects an under-target round.
60
66
 
61
67
  Run both company-level and requisition-level duplicate checks before filling and again immediately before transmission. Hard ledger-ID, canonical-URL, employer-job-ID, and requisition duplicates always stop. Same-role aliases require a verified distinct requisition and `NEW REQUISITION CONFIRMED`. A genuinely different role at the same company may proceed automatically only when `companyReapply.decision` is `eligible-after-cooldown`: 15 full days have passed since the latest company application and no outcome has been recorded. `cooldown-active` and `follow-up-present` require explicit candidate approval.
@@ -221,3 +221,7 @@ Generate `ledger review` first. Acknowledge only after the candidate has reviewe
221
221
  The acknowledgement stores the current canonical unique-submission and mature-application counts in append-only `reviews.ndjson`.
222
222
 
223
223
  `ledger review` also returns `interviewLearningSegments`. Each row combines the canonical application's source and ten-point fit-score band with the latest recorded interview quality and failure point, plus a count. This supports evidence-based targeting reviews without exposing private notes or automatically changing score weights.
224
+
225
+ ## Delivery and per-lead accounting
226
+
227
+ See [ACCOUNTING.md](ACCOUNTING.md) for versioned delivery events, explicit corrections, linked retry evidence, lead dispositions/revisions, and effective round/review output fields. These streams are private and append-only; they never become telemetry properties.
@@ -0,0 +1,246 @@
1
+ // Shared by the packaged CLI and private Worker. No candidate state or network access.
2
+ import { createHash } from 'node:crypto';
3
+
4
+ export const ACCOUNTING_CAPABILITY = 'application-accounting-v1';
5
+ export const DISPOSITIONS = Object.freeze(['qualified', 'duplicate', 'no-relevant-opening', 'location-authorization-conflict', 'compensation-below-floor', 'seniority-mismatch', 'insufficient-must-have-coverage', 'closed-stale', 'blocked']);
6
+ const DELIVERY_TYPES = ['receipt-confirmed', 'delivery-failed', 'correction', 'retry-confirmed'];
7
+ const EVIDENCE_TYPES = ['employer-acknowledgement', 'final-delivery-failure', 'browser-confirmation', 'sent-email', 'ambiguous', 'delivery-delay'];
8
+ function required(value, name, max = 300) {
9
+ if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} must be nonempty text (maximum ${max}).`);
10
+ return value;
11
+ }
12
+ function date(value, name) { required(value, name, 80); if (!Number.isFinite(Date.parse(value))) throw new Error(`${name} must be an ISO date.`); return new Date(value).toISOString(); }
13
+ function object(input, allowed) {
14
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Accounting event must be an object.');
15
+ for (const key of Object.keys(input)) if (!allowed.includes(key)) throw new Error(`Unknown accounting field: ${key}.`);
16
+ }
17
+ export function canonicalUrl(value) {
18
+ const url = new URL(required(value, 'url', 2048));
19
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('A public HTTP(S) URL is required.');
20
+ url.hash = '';
21
+ // Unknown parameters may identify a requisition (for example SAP career_job_req_id).
22
+ for (const key of [...url.searchParams.keys()]) if (/^(utm_.*|ref|refid|referrer|source|gh_src|tracking_?id|trk|trkinfo|gclid|fbclid|msclkid|mc_cid|mc_eid|lever-source|lever-origin)$/i.test(key)) url.searchParams.delete(key);
23
+ url.searchParams.sort();
24
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/';
25
+ return url.href;
26
+ }
27
+ export function stableJson(value) {
28
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
29
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
30
+ return JSON.stringify(value);
31
+ }
32
+ const digest = value => createHash('sha256').update(stableJson(value)).digest('hex');
33
+ export function eventId(value, prefix) { return `${prefix}-${digest(value)}`; }
34
+ export function validateDelivery(input) {
35
+ object(input, ['version','id','applicationId','attemptId','type','occurredAt','evidenceType','evidence','messageRef','supersedes','status','channel','url','channelVerifiedAt','approval']);
36
+ const v = { ...input, version: 1 };
37
+ if (input.version != null && input.version !== 1) throw new Error('Unsupported delivery version.');
38
+ for (const name of ['applicationId','attemptId']) required(v[name], name);
39
+ if (!DELIVERY_TYPES.includes(v.type)) throw new Error('Invalid delivery type.');
40
+ if (!EVIDENCE_TYPES.includes(v.evidenceType)) throw new Error('Invalid evidenceType.');
41
+ required(v.evidence, 'evidence', 2000);
42
+ v.occurredAt = date(v.occurredAt, 'occurredAt');
43
+ if (v.messageRef != null) required(v.messageRef, 'messageRef', 500);
44
+ if (v.type === 'correction') {
45
+ if (!['receipt-confirmed','delivery-failed','unknown'].includes(v.status)) throw new Error('Invalid correction status.');
46
+ const refs = Array.isArray(v.supersedes) ? v.supersedes : [v.supersedes];
47
+ if (!refs.length || refs.length > 100) throw new Error('Correction requires superseded event IDs.');
48
+ refs.forEach(ref => required(ref,'supersedes'));
49
+ v.supersedes = [...new Set(refs)].sort();
50
+ } else if (v.supersedes != null || v.status != null) throw new Error('Only corrections supersede delivery evidence.');
51
+ if (v.type === 'retry-confirmed') {
52
+ if (!['email','browser'].includes(v.channel)) throw new Error('Retry channel must be email or browser.');
53
+ v.url = canonicalUrl(v.url);
54
+ v.channelVerifiedAt = date(v.channelVerifiedAt,'channelVerifiedAt');
55
+ if (Date.parse(v.channelVerifiedAt) > Date.parse(v.occurredAt) || Date.parse(v.occurredAt) - Date.parse(v.channelVerifiedAt) > 86400000) throw new Error('Retry channel must be verified within 24 hours before transmission.');
56
+ if (!['STANDING AUTHORIZATION','APPROVE SUBMIT'].includes(v.approval)) throw new Error('Retry requires existing candidate authorization.');
57
+ if (v.evidenceType !== (v.channel === 'email' ? 'sent-email' : 'browser-confirmation')) throw new Error('Retry requires verified transmission evidence.');
58
+ if (v.attemptId.startsWith('initial:')) throw new Error('Retry requires a new attemptId.');
59
+ } else if (['channel','url','channelVerifiedAt','approval'].some(k => v[k] != null)) throw new Error('Transmission fields are only allowed on retry-confirmed.');
60
+ v.id ??= eventId(v, 'delivery');
61
+ required(v.id, 'id');
62
+ return v;
63
+ }
64
+
65
+ // A revision graph, never last-write-wins. Different children of one revision conflict.
66
+ function heads(events) {
67
+ const versions = new Map();
68
+ for (const e of events) {
69
+ const key = stableJson(e);
70
+ if (!versions.has(e.id)) versions.set(e.id, new Map());
71
+ versions.get(e.id).set(key, e);
72
+ }
73
+ const all = [...versions.values()].flatMap(v => [...v.values()]);
74
+ const invalidIds = new Set([...versions].filter(([,v]) => v.size > 1).map(([id]) => id));
75
+ const referenced = new Set(all.flatMap(e => e.supersedes == null ? [] : Array.isArray(e.supersedes) ? e.supersedes : [e.supersedes]));
76
+ const missing = [...referenced].some(id => !versions.has(id));
77
+ const current = all.filter(e => !referenced.has(e.id));
78
+ return { current, conflict: invalidIds.size > 0 || missing || (all.length > 0 && !current.length) };
79
+ }
80
+ function refs(event) { return event.supersedes == null ? [] : Array.isArray(event.supersedes) ? event.supersedes : [event.supersedes]; }
81
+ const normalized = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
82
+ export function accountingApplicationKey(entry, fallback = '') {
83
+ if (entry.employerJobId) return `job:${normalized(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
84
+ if (entry.company && entry.role) return `legacy-role:${normalized(entry.company)}:${normalized(entry.role)}`;
85
+ if (entry.url) return `url:${canonicalUrl(entry.url).replace(/\/$/, '').toLowerCase()}`;
86
+ return `id:${entry.id ?? fallback}`;
87
+ }
88
+ export function deliveryProjection(entries, events = []) {
89
+ const applications = entries.map(app => {
90
+ const selected = events.filter(e => e.version === 1 && e.applicationId === app.id && DELIVERY_TYPES.includes(e.type));
91
+ const initial = { attemptId: `initial:${app.id}`, channel: (app.applicationChannel ?? app.source) === 'email' ? 'email' : 'browser', url: app.url };
92
+ // A stable representative keeps even conflicting retry projections independent of arrival order.
93
+ const retries = [...new Map(selected.filter(e => e.type === 'retry-confirmed').sort((a, b) => stableJson(a).localeCompare(stableJson(b))).map(e => [e.attemptId, e])).values()];
94
+ const attempts = [initial, ...retries].map(attempt => {
95
+ const observations = selected.filter(e => e.attemptId === attempt.attemptId && e.type !== 'retry-confirmed');
96
+ const graph = heads(observations);
97
+ const states = new Set();
98
+ const ancestors = event => {
99
+ const seen = new Set();
100
+ const pending = [...refs(event)];
101
+ while (pending.length) {
102
+ const id = pending.pop();
103
+ if (seen.has(id)) continue;
104
+ seen.add(id);
105
+ const parent = observations.find(e => e.id === id);
106
+ if (parent) pending.push(...refs(parent));
107
+ }
108
+ return seen;
109
+ };
110
+ const correctionFork = graph.current.some((left, index) => graph.current.slice(index + 1).some(right => {
111
+ if (left.status === right.status) return false;
112
+ const lineage = ancestors(left);
113
+ return [...ancestors(right)].some(id => lineage.has(id));
114
+ }));
115
+ let ambiguous = graph.conflict || correctionFork;
116
+ for (const e of graph.current) {
117
+ const status = e.type === 'correction' ? e.status : e.type;
118
+ if (status === 'unknown') continue;
119
+ if (e.evidenceType === 'delivery-delay') continue;
120
+ if (e.evidenceType === 'ambiguous') { ambiguous = true; continue; }
121
+ if (status === 'delivery-failed') {
122
+ if (e.evidenceType !== 'final-delivery-failure') { ambiguous = true; continue; }
123
+ if (attempt.channel === 'email') states.add('failed');
124
+ } else if (status === 'receipt-confirmed') {
125
+ if (!['employer-acknowledgement','browser-confirmation'].includes(e.evidenceType)) { ambiguous = true; continue; }
126
+ states.add('received');
127
+ }
128
+ }
129
+ const retryCopies = selected.filter(e => e.type === 'retry-confirmed' && e.attemptId === attempt.attemptId);
130
+ if (new Set(retryCopies.map(stableJson)).size > 1) ambiguous = true;
131
+ const conflict = ambiguous || states.size > 1;
132
+ const failed = !conflict && states.has('failed');
133
+ return { ...attempt, failed, conflict, counted: !failed, receiptUnknown: attempt.channel === 'email' && !failed && !states.has('received'), status: conflict ? 'needs-review' : failed ? 'delivery-failed' : attempt.channel !== 'email' || states.has('received') ? 'receipt-confirmed' : 'receipt-unknown' };
134
+ });
135
+ const orphan = selected.some(e => !attempts.some(a => a.attemptId === e.attemptId));
136
+ return { applicationId: app.id, counted: attempts.some(a => a.counted), failed: attempts.every(a => a.failed), receiptUnknown: attempts.some(a => a.counted && a.receiptUnknown), conflict: orphan || attempts.some(a => a.conflict), attempts };
137
+ });
138
+ // Preserve the established canonical grouping contract (job ID, otherwise URL).
139
+ const groups = new Map();
140
+ entries.forEach((app,index) => {
141
+ const key = accountingApplicationKey(app, index);
142
+ if (!groups.has(key)) groups.set(key, []);
143
+ groups.get(key).push(applications[index]);
144
+ });
145
+ const canonical = [...groups.values()];
146
+ return { applications, recordedSubmissionCount: canonical.length, effectiveSubmissionCount: canonical.filter(g => g.some(a => a.counted)).length, failedDeliveryCount: canonical.filter(g => g.every(a => a.failed)).length, receiptUnknownEmailCount: canonical.filter(g => g.some(a => a.receiptUnknown)).length };
147
+ }
148
+ export function validateDeliveryReferences(event, applications, events, { preparedRetry = false } = {}) {
149
+ const app = applications.find(a => a.id === event.applicationId);
150
+ if (!app) throw new Error('Delivery applicationId is not recorded.');
151
+ const existing = events.filter(e => e.id === event.id);
152
+ if (existing.some(e => stableJson(e) !== stableJson(event))) throw new Error('Conflicting event ID; append a correction with a new ID.');
153
+ if (existing.length) return;
154
+ if (event.type === 'retry-confirmed') {
155
+ const related = applications.filter(a => accountingApplicationKey(a) === accountingApplicationKey(app));
156
+ const all = deliveryProjection(related, events).applications;
157
+ const current = all.find(a => a.applicationId === app.id);
158
+ if (!preparedRetry && all.some(a => !a.failed || a.conflict)) throw new Error('Retry requires verified failure of every prior attempt and no conflicts.');
159
+ if (current.attempts.some(a => a.attemptId === event.attemptId)) throw new Error('Retry attemptId already exists.');
160
+ } else if (event.attemptId !== `initial:${app.id}` && !events.some(e => e.type === 'retry-confirmed' && e.attemptId === event.attemptId && e.applicationId === app.id)) throw new Error('Delivery attemptId is not recorded.');
161
+ for (const id of refs(event)) {
162
+ const parent = events.find(e => e.id === id);
163
+ if (!parent || parent.type === 'retry-confirmed' || parent.applicationId !== event.applicationId || parent.attemptId !== event.attemptId) throw new Error('Correction must reference existing evidence for the same attempt.');
164
+ }
165
+ }
166
+
167
+ export function validateLead(input) {
168
+ object(input,['version','type','id','roundId','sourceId','url','company','role','employerJobId','disposition','observedAt','evidence','applicationId','supersedes']);
169
+ if (input.version != null && input.version !== 1) throw new Error('Unsupported discovery version.');
170
+ if (input.type != null && input.type !== 'lead-reviewed') throw new Error('Invalid discovery event type.');
171
+ const v = { ...input, version: 1, type: 'lead-reviewed', url: canonicalUrl(input.url) };
172
+ for (const key of ['roundId','sourceId','company']) required(v[key],key);
173
+ for (const key of ['role','employerJobId','applicationId']) if(v[key] != null) required(v[key],key);
174
+ if (!DISPOSITIONS.includes(v.disposition)) throw new Error('Invalid lead disposition.');
175
+ v.observedAt = date(v.observedAt,'observedAt');
176
+ required(v.evidence,'evidence',2000);
177
+ if (v.supersedes != null) {
178
+ const parents = Array.isArray(v.supersedes) ? v.supersedes : [v.supersedes];
179
+ if (!parents.length || parents.length > 100) throw new Error('Invalid lead supersedes.');
180
+ parents.forEach(id => required(id,'supersedes'));
181
+ v.supersedes = [...new Set(parents)].sort();
182
+ }
183
+ v.id ??= eventId(v, 'lead'); required(v.id,'id');
184
+ return v;
185
+ }
186
+ function requisitionKey(e) { return e.employerJobId ? `${normalized(e.company)}:${e.employerJobId.toLowerCase()}` : canonicalUrl(e.url); }
187
+ export function leadKey(e) { return `${e.roundId}:${e.sourceId}:${requisitionKey(e)}`; }
188
+ export function validateLeadReferences(event, events) {
189
+ for (const previous of events.filter(e => e.id === event.id)) if (stableJson(previous) !== stableJson(event)) throw new Error('Conflicting lead event ID.');
190
+ const history = new Map(events.filter(e => e.version === 1 && e.type === 'lead-reviewed').map(e => [e.id, e]));
191
+ const parents = refs(event).map(id => history.get(id));
192
+ const sameScope = parent => parent && parent.roundId === event.roundId && parent.sourceId === event.sourceId && normalized(parent.company) === normalized(event.company);
193
+ const ancestors = parent => {
194
+ const seen = new Set(); const pending = [parent.id];
195
+ while (pending.length) {
196
+ const id = pending.pop(); const previous = history.get(id);
197
+ if (seen.has(id) || !sameScope(previous)) continue;
198
+ seen.add(id); pending.push(...refs(previous));
199
+ }
200
+ return seen;
201
+ };
202
+ // A disputed enrichment can choose an already-recorded ID only by explicitly
203
+ // superseding branches from the same lineage, never unrelated requisitions.
204
+ const lineages = parents.length > 1 && parents.every(sameScope) ? parents.map(ancestors) : [];
205
+ const resolvesFork = Boolean(event.employerJobId) && lineages.length > 1 && parents.some(parent => leadKey(parent) === leadKey(event)) && [...lineages[0]].some(id => lineages.every(lineage => lineage.has(id)));
206
+ for (const parent of parents) {
207
+ const enrichment = sameScope(parent) && canonicalUrl(parent.url) === canonicalUrl(event.url) && (!parent.employerJobId && Boolean(event.employerJobId));
208
+ if (!parent || (leadKey(parent) !== leadKey(event) && !enrichment && !resolvesFork)) throw new Error('Lead revision must reference the same round, source and requisition.');
209
+ }
210
+ }
211
+ export function discoveryProjection(events, { roundId } = {}) {
212
+ const selected = events.filter(e => e.version === 1 && e.type === 'lead-reviewed' && (roundId == null || e.roundId === roundId));
213
+ const aliases = new Map();
214
+ const aliasKey = e => `${e.roundId}:${normalized(e.company)}:${canonicalUrl(e.url)}`;
215
+ for (const e of selected.filter(e => e.employerJobId)) {
216
+ const key = aliasKey(e);
217
+ if (!aliases.has(key)) aliases.set(key, new Set());
218
+ aliases.get(key).add(requisitionKey(e));
219
+ }
220
+ const resolvedKey = e => !e.employerJobId && aliases.get(aliasKey(e))?.size === 1 ? [...aliases.get(aliasKey(e))][0] : requisitionKey(e);
221
+ const keyFor = e => `${e.roundId}:${e.sourceId}:${resolvedKey(e)}`;
222
+ const roots = new Map(selected.map(e => [keyFor(e), keyFor(e)]));
223
+ const root = key => { while (roots.get(key) !== key) key = roots.get(key); return key; };
224
+ const byId = new Map();
225
+ for (const e of selected) { if (!byId.has(e.id)) byId.set(e.id, []); byId.get(e.id).push(e); }
226
+ // Explicit revisions keep their lineage even when a shared careers URL later
227
+ // acquires another requisition. Forked enrichments stay together for conflict review.
228
+ for (const e of selected) for (const id of [e.id, ...refs(e)]) for (const parent of byId.get(id) ?? []) {
229
+ if (parent.roundId !== e.roundId || parent.sourceId !== e.sourceId) continue;
230
+ const left = root(keyFor(e)); const right = root(keyFor(parent));
231
+ if (left !== right) roots.set(left < right ? right : left, left < right ? left : right);
232
+ }
233
+ const groups = new Map();
234
+ for (const e of selected) { const key = root(keyFor(e)); if (!groups.has(key)) groups.set(key,[]); groups.get(key).push(e); }
235
+ const leads = []; const conflicts = [];
236
+ for (const [key, values] of groups) {
237
+ const graph = heads(values);
238
+ const comparable = e => stableJson(Object.fromEntries(Object.entries({ ...e, company: normalized(e.company), ...(e.employerJobId ? { employerJobId: e.employerJobId.toLowerCase() } : { url: canonicalUrl(e.url) }) }).filter(([k]) => !['id','observedAt','supersedes', ...(e.employerJobId ? ['url'] : [])].includes(k))));
239
+ const conflict = graph.conflict || new Set(graph.current.map(comparable)).size > 1;
240
+ if (conflict) conflicts.push({ key, eventIds: [...new Set(values.map(e => e.id))].sort() });
241
+ const lead = [...graph.current].sort((a,b) => a.id.localeCompare(b.id))[0] ?? values[0];
242
+ leads.push({ ...lead, key, conflict });
243
+ }
244
+ leads.sort((a,b) => a.key.localeCompare(b.key));
245
+ return { leads, conflicts, reviewedCount: leads.length, qualifiedCount: leads.filter(e => !e.conflict && e.disposition === 'qualified').length, uniqueLeadCount: new Set(leads.map(resolvedKey)).size, dispositionCounts: Object.fromEntries(DISPOSITIONS.map(d => [d,leads.filter(e => !e.conflict && e.disposition === d).length])) };
246
+ }
@@ -1,3 +1,4 @@
1
+ import { ACCOUNTING_CAPABILITY, stableJson, validateDelivery, validateLead } from './application-accounting.mjs';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
4
  import { homedir, platform } from 'node:os';
@@ -8,6 +9,7 @@ export const CLOUD_STREAM_FILES = Object.freeze({
8
9
  outcomes: 'outcomes.ndjson',
9
10
  rounds: 'rounds.ndjson',
10
11
  discovery: 'discovery.ndjson',
12
+ delivery: 'delivery.ndjson',
11
13
  attention: 'attention.ndjson',
12
14
  reviews: 'reviews.ndjson',
13
15
  friction: 'friction.ndjson',
@@ -93,6 +95,7 @@ export async function enableCloudUpdateGuard({ home = homedir(), agentHome = pro
93
95
  catch (error) { if (error.code === 'ENOENT') return { guarded: false, reason: 'managed-install-not-found' }; throw error; }
94
96
  const required = new Set(config.requiredCapabilities ?? []);
95
97
  required.add('cloud-state-v2');
98
+ required.add(ACCOUNTING_CAPABILITY);
96
99
  await privateWrite(path, `${JSON.stringify({ ...config, requiredCapabilities: [...required].sort() }, null, 2)}\n`);
97
100
  return { guarded: true, capability: 'cloud-state-v2' };
98
101
  }
@@ -159,6 +162,23 @@ export class CloudStateClient {
159
162
  return { configured: true, url: config.url, configuredClient: { id: config.clientId ?? null, name: config.clientName ?? null, token: tokenSuffix(config.token) }, pendingLocalWrites: pending.length, ...remote };
160
163
  }
161
164
 
165
+ async requireAccounting() {
166
+ const config = await this.config(true);
167
+ if (!config) return;
168
+ const backend = hash(`${config.url}:${config.token}`);
169
+ try {
170
+ const status = await this.status();
171
+ if (!status.configured) return;
172
+ if (!status.capabilities?.includes(ACCOUNTING_CAPABILITY)) throw new Error('Private backend upgrade required: application-accounting-v1 is missing.');
173
+ await ensurePrivateDirectory(this.stateDir);
174
+ await privateWrite(join(this.stateDir, 'cloud-accounting-capability.json'), JSON.stringify({ supported: true, backend }));
175
+ } catch (error) {
176
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
177
+ try { if (JSON.parse(await readFile(join(this.stateDir, 'cloud-accounting-capability.json'), 'utf8')).backend === backend) return; } catch {}
178
+ throw error;
179
+ }
180
+ }
181
+
162
182
  async getDocument(name) {
163
183
  const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`);
164
184
  return response.json();
@@ -199,7 +219,7 @@ export class CloudStateClient {
199
219
  if (forbidden) throw new Error(`Cloud record contains forbidden field: ${forbidden}`);
200
220
  const payload = {
201
221
  recordKey: String(recordKey ?? value?.id ?? value?.roundId ?? randomUUID()),
202
- idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(JSON.stringify(value))}`),
222
+ idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(stableJson(value))}`),
203
223
  occurredAt: occurredAt ?? value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
204
224
  provenance,
205
225
  value,
@@ -227,8 +247,20 @@ export class CloudStateClient {
227
247
  }
228
248
 
229
249
  async pendingWrites() {
230
- try { return (await readFile(join(this.stateDir, 'cloud-pending.ndjson'), 'utf8')).split('\n').filter(Boolean).map(JSON.parse); }
231
- catch (error) { if (error.code === 'ENOENT') return []; throw error; }
250
+ const events = await readNdjson(join(this.stateDir, 'cloud-pending.ndjson'));
251
+ const receipts = new Set((await readNdjson(join(this.stateDir, 'cloud-pending-receipts.ndjson'))).map(event => event.key));
252
+ return events.filter(event => !receipts.has(hash(stableJson(event))));
253
+ }
254
+
255
+ async flushAccountingWrites(stream) {
256
+ if (!['delivery','discovery'].includes(stream)) return;
257
+ for (const event of (await this.pendingWrites()).filter(event => event.type === 'append-record' && event.stream === stream)) {
258
+ await this.appendRecord(stream, event.payload.value, { ...event.payload, queueOnFailure: false });
259
+ const receipt = { key: hash(stableJson(event)) };
260
+ const path = join(this.stateDir, 'cloud-pending-receipts.ndjson');
261
+ await appendFile(path, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
262
+ await chmod(path, 0o600);
263
+ }
232
264
  }
233
265
 
234
266
  async putFile(name, bytes, revision) {
@@ -289,9 +321,15 @@ export class CloudStateClient {
289
321
  }
290
322
 
291
323
  async createIntent(value) {
324
+ await this.requireAccounting();
292
325
  return (await this.request('/v2/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) })).json();
293
326
  }
294
327
 
328
+ async confirmRetry(intentId, delivery, leaseId) {
329
+ await this.requireAccounting();
330
+ return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ delivery, leaseId }) })).json();
331
+ }
332
+
295
333
  async markIntentSentUnverified(intentId, leaseId) {
296
334
  return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/sent-unverified`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ leaseId }) })).json();
297
335
  }
@@ -301,11 +339,23 @@ export class CloudStateClient {
301
339
  }
302
340
 
303
341
  async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
342
+ await this.requireAccounting();
304
343
  const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
305
344
  for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
306
- const local = await readNdjson(join(this.stateDir, filename));
345
+ if (!dryRun) await this.flushAccountingWrites(stream);
346
+ const normalize = value => stream === 'delivery' ? validateDelivery(value) : stream === 'discovery' && value.version === 1 && value.type === 'lead-reviewed' ? validateLead(value) : value;
347
+ let local = (await readNdjson(join(this.stateDir, filename))).map(normalize);
307
348
  const cloudRecords = await this.listStream(stream);
308
- const cloud = cloudRecords.map((record) => record.value);
349
+ let cloud = cloudRecords.map((record) => normalize(record.value));
350
+ if (['delivery','discovery'].includes(stream)) {
351
+ const ids = new Map();
352
+ for (const value of [...local, ...cloud].filter(v => v.version === 1 && (stream === 'delivery' || v.type === 'lead-reviewed'))) {
353
+ if (ids.has(value.id) && stableJson(ids.get(value.id)) !== stableJson(value)) throw new Error('Conflicting accounting event ID during cloud reconciliation.');
354
+ ids.set(value.id, value);
355
+ }
356
+ const unique = values => values.filter((value,index) => value.version !== 1 || (stream === 'discovery' && value.type !== 'lead-reviewed') || values.findIndex(other => other.id === value.id && other.version === 1) === index);
357
+ local = unique(local); cloud = unique(cloud);
358
+ }
309
359
  const localCounts = multiset(local);
310
360
  const cloudCounts = multiset(cloud);
311
361
  const localOnly = multisetDifference(local, cloudCounts);
@@ -314,7 +364,7 @@ export class CloudStateClient {
314
364
  if (!dryRun) {
315
365
  const prepared = localOnly.map(({ value, index }) => ({
316
366
  recordKey: String(value?.id ?? value?.roundId ?? value?.applicationId ?? `${stream}-${index}`),
317
- idempotencyKey: `reconcile:${hash(JSON.stringify(value))}:${index}`,
367
+ idempotencyKey: value.version === 1 && (stream === 'delivery' || (stream === 'discovery' && value.type === 'lead-reviewed')) ? `accounting:${value.id}` : `reconcile:${hash(stableJson(value))}:${index}`,
318
368
  occurredAt: value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
319
369
  provenance,
320
370
  value,
@@ -353,7 +403,7 @@ async function readNdjson(path) {
353
403
  }
354
404
 
355
405
  function key(value) {
356
- return JSON.stringify(value);
406
+ return stableJson(value);
357
407
  }
358
408
 
359
409
  function multiset(values) {