Skip to main content

Sysco — X12 EDI over SFTP

Sysco is the non-negotiable integration and the only supplier with a return path. Outbound, the worker generates an ANSI ASC X12 850 Purchase Order and drops it on Sysco's SFTP server, where Sysco's ERP ingests it. Inbound, 855 Purchase Order Acknowledgments and 997 Functional Acknowledgments are parsed and applied.

Verified against source

This page was verified line by line against the implementation, not against the technical plan. The files checked are apps/worker/src/edi/po-850.ts, x12-writer.ts, envelope.ts, parse-855.ts, parse-997.ts, control-numbers.ts, apps/worker/src/worker.ts, apps/worker/src/jobs/sysco-poll.ts, apps/worker/src/handlers/sysco.ts, handlers/supplier-dispatch.ts, handlers/backorder-reroute.ts, apps/worker/src/supplier-config.ts, apps/worker/src/sftp/client.ts, apps/api/src/domain/inbound-ack.ts, apps/api/src/routes/inbound/sysco.ts, apps/api/src/queues/index.ts, apps/api/src/config/index.ts, packages/types/src/queue.ts and packages/types/src/enums.ts, together with the tests under apps/worker/tests/, the golden fixtures in apps/worker/tests/fixtures/ and the CronJob definitions in infra/dev/lucille-sysco-poll.yaml and infra/prod/lucille-sysco-poll.yaml. Where a claim below could not be verified in code it says so explicitly.

The worker runtime was under active development while this page was written. Files appeared in apps/worker/src/ during the review itself. If a detail here looks off — particularly around the queue wiring, the poll job or worker concurrency — re-check apps/worker/src/ and apps/api/src/queues/ before trusting the page.

Sysco's Trading Partner Implementation Guide is required

Sysco does not publish a public 850/855 implementation guide. The only public Sysco guide (via Stedi) covers the 810 Invoice, and Stedi itself says to contact Sysco for official specifications. Interchange identifiers are not universal — they differ per trading partner and per Sysco location.

You must obtain Sysco's Trading Partner Implementation Guide (TIG) from your Sysco EDI onboarding contact to confirm the ISA05/ISA06 and ISA07/ISA08 qualifier and ID values, which segments Sysco treats as required, the product ID qualifier expected in PO106, the filename convention, and whether version 004010 or 005010 applies. Obtain separate test SFTP credentials as well.

No partner-specific value is hardcoded. They arrive through the partner argument of generatePurchaseOrder850, which the dispatch handler fills from suppliers.config layered over the EDI_* environment variables (EDI_ISA_SENDER_QUALIFIER, EDI_ISA_SENDER_ID, EDI_ISA_RECEIVER_QUALIFIER, EDI_ISA_RECEIVER_ID, EDI_VERSION, EDI_PRODUCT_ID_QUALIFIER, EDI_TEST_INDICATOR). Applying the TIG is a configuration change. The defaults — ZZ qualifiers, LUCILLE/SYSCO IDs, 004010, VN, T — are the ANSI baseline, not confirmed Sysco values. This is decision D3 in IMPLEMENTATION.md.

Outbound: X12 850, segment by segment

This is apps/worker/tests/fixtures/expected-850.edi, the committed golden file that generatePurchaseOrder850 reproduces byte for byte for a three-line order. It is shown one segment per line for readability; the emitted file contains no newlines at all (newlineAfterSegment defaults to false), so every segment is terminated by ~ and nothing else.

ISA*00* *00* *ZZ*LUCILLE *ZZ*SYSCO *260813*1405*U*00401*000000042*0*T*>~
GS*PO*LUCILLE*SYSCO*20260813*1405*7*X*004010~
ST*850*0001~
BEG*00*SA*PO-1001**20260813~
REF*ST*LUC001~
DTM*097*20260813~
N1*ST*Lucille Downtown~
N3*123 Main St~
N4*Springfield*IL*62704~
PO1*1*4*CA***VN*SYS-10045~
PID*F****Chicken Breast 6x10oz~
PO1*2*2*GA*38.50*PE*VN*SYS-20881~
PID*F****Olive Oil 4x1gal~
PO1*3*50*LB***VN*SYS-33120~
PID*F****Yukon Gold Potatoes 50 lb bag~
CTT*3~
SE*15*0001~
GE*1*7~
IEA*1*000000042~

The order is fixed: ISA, GS, ST, BEG, REF, DTM, then the optional N1/N3/N4 loop, then one PO1 + PID pair per line item, then CTT, SE, GE, IEA.

SegmentEmittedElements the serialiser populates
ISAAlwaysSixteen fixed-width elements, exactly 106 characters including the terminator. ISA01/ISA03 = 00, ISA02/ISA04 blank, ISA05ISA08 from config, ISA09 YYMMDD UTC, ISA10 HHMM UTC, ISA11 U, ISA12 derived from the EDI version (00401000401), ISA13 nine digits zero-padded, ISA14 0 (no TA1 requested), ISA15 T unless configured P, ISA16 the component separator
GSAlwaysGS01 = PO; GS02/GS03 are the trimmed sender/receiver IDs; GS04 CCYYMMDD, GS05 HHMM; GS06 the group control number unpadded; GS07 = X; GS08 the configured version
STAlwaysST01 = 850; ST02 the transaction control number zero-padded to four digits, reused verbatim as SE02
BEGAlwaysBEG01 = 00 (Original), BEG02 = SA (Stand-alone Order), BEG03 the order number, BEG04 deliberately empty, BEG05 the document date
REFAlwaysREF01 = ST (Store Number), REF02 the restaurant code. Exactly one REF; no other qualifier is written
DTMAlways, exactly oneDTM*010*<date> when a requested ship date exists, otherwise DTM*097*<document date>. 002 (requested delivery) is never emitted, because no delivery date is captured
N1 loopOnly with a locationN1*ST*<restaurant name>N103/N104 are not written. N3 only when a street was parsed out of the free-text location; N4 only when at least one of city/state/postal code was. No N2, no REF inside the loop, no BT/VN loop
PO1One per line itemPO101 sequential from 1, PO102 quantity, PO103 mapped UOM, PO104 price to two decimals, PO105 = PE, PO106 the configured product ID qualifier, PO107 the supplier SKU. PO104 and PO105 are omitted together when the price is unknown — sending 0 would be a priced order at zero
PIDOne per line itemPID*F****<description>PID01 = F, PID02PID04 empty, PID05 the item name plus unit size, truncated to 80 characters
CTTAlwaysCTT01 = the number of PO1 segments. CTT02 (hash total of PO102) is not emitted
SEAlwaysSE01 counted from ST through SE inclusive; SE02 = ST02
GEAlwaysGE01 the transaction-set count, GE02 = GS06
IEAAlwaysIEA01 the functional-group count, IEA02 = ISA13 with its zero padding

Nothing else is written. There is no CUR, PER, ITD, TD5, SAC, MSG or AMT segment, no schedule (SCH) loop and no second N1 loop. In the current dispatch path the job payload carries no unit price, so real production output has empty PO104/PO105 on every line; the priced form above is exercised by the golden fixture. generatePurchaseOrder850 throws rather than emit an 850 with no line items.

The writer refuses to serialise a value containing an element, segment or component separator: X12 has no release character, so X12ValidationError is raised instead of shipping an interchange the partner would mis-split. CR/LF inside an element is rejected on the same grounds.

ISA fixed-width padding

The ISA is positional. ISA_ELEMENT_WIDTHS in x12-writer.ts is 2, 10, 2, 10, 2, 15, 2, 15, 6, 4, 1, 5, 9, 1, 1, 1 — 86 characters of data, plus the three-character tag, sixteen element separators and the terminator, which is the 106 total that ISA_SEGMENT_LENGTH asserts. Every element is right-padded with spaces except ISA13, which is left-padded with zeros:

function padIsaElement(raw: string, width: number, position: number, context: string): string {
if (raw.length > width) {
throw new X12ValidationError(
`ISA${String(position).padStart(2, '0')} (${context}) is ${raw.length} characters but the ` +
`field is exactly ${width}: ${JSON.stringify(raw)}`,
);
}
return ISA_NUMERIC_POSITIONS.has(position) ? raw.padStart(width, '0') : raw.padEnd(width, ' ');
}

An over-long ID fails the job rather than being silently truncated, and the assembled segment is re-measured against 106 before anything is written. Get this wrong and the interchange dies at Sysco's gateway before any business validation, so the failure comes back as a 997 rather than a useful message.

SE01 segment counting

SE01 is the number of segments from ST through SE inclusive, and it is never passed in. The writer records the index of the ST it wrote and derives the count when the transaction set is closed:

closeTransactionSet(): number {
const open = this.transaction;
if (!open) throw new X12ValidationError('SE written with no open transaction set');
const count = this.segments.length - open.startIndex + 1;
this.push('SE', [String(count), open.controlNumber]);
this.transaction = undefined;
this.lastTransactionSegmentCount = count;
if (this.group) this.group.transactionCount += 1;
return count;
}

ISA, GS, ST, SE, GE and IEA are reserved tags — calling write() with one of them throws, so the counts and control numbers cannot drift. The tests assert the invariant by parsing the generated file, counting the segments between ST and SE, and comparing with the declared SE01; a further test drops the N1 loop and one line item and checks that SE01 falls by exactly five.

Control-number allocation

ISA13 must be unique and monotonically increasing; a reused number is either rejected or silently treated as a duplicate. All three counters live in one row per supplier in app.edi_control_numbers and are advanced by a single statement, so two worker replicas — or two BullMQ attempts — can never be handed the same number. The row is also created on first use, so a new supplier needs no seeding step:

INSERT INTO app.edi_control_numbers AS ecn (
supplier_id, isa_control_number, group_control_number, transaction_control_number, updated_at
)
VALUES ($1::uuid, 1, 1, 1, now())
ON CONFLICT (supplier_id) DO UPDATE
SET isa_control_number = (ecn.isa_control_number % 999999999) + 1,
group_control_number = (ecn.group_control_number % 999999999) + 1,
transaction_control_number = (ecn.transaction_control_number % 999999999) + 1,
updated_at = now()
RETURNING ecn.isa_control_number, ecn.group_control_number, ecn.transaction_control_number;

INSERT … ON CONFLICT DO UPDATE … RETURNING takes a row lock for the duration of the statement, which serialises every concurrent allocation; a test allocates 50 in parallel and asserts 50 distinct contiguous numbers. Numbers are reserved before serialisation and inside the job, so a failed job leaves a gap — X12 requires uniqueness and monotonicity, not density. At 999999999 the counters wrap to 1 rather than overflow the nine-character field; that technically breaks "never reused", which is accepted because any partner's duplicate-detection window is months, not millennia. peekControlNumbers() reads the last-issued values for diagnostics and must never be used to derive the next one.

Two consequences of the three counters sharing one value are worth knowing. GS06 is emitted unpadded while ISA13 is padded to nine digits, so the same underlying number appears as 7 and 000000042 in different interchanges only because they were allocated at different times. And ST02 is padded to four digits by padControlNumber(value, 4), which throws when the number no longer fits — so the 10,000th interchange for a supplier fails to serialise. That ceiling is in the code and is not covered by a test; it is a real limitation, not a documented design decision.

File naming

PO_{restaurantCode}_{orderNumber}_{yyyyMMddHHmmss}.edi

PO_LUC001_PO-1001_20260813140509.edi

The timestamp is UTC, from the injected clock. Both tokens are pushed through sanitizeFilenameToken, which collapses anything outside [A-Za-z0-9._-] to -, strips leading and trailing dashes, and yields UNKNOWN for an empty result — so a restaurant code of LUC/001 and an order number of ../../etc/passwd become PO_LUC-001_..-..-etc-passwd_20260813140509.edi and can never escape the remote directory. restaurantCode is derived from the restaurant name by the dispatch handler, because restaurants has no code column.

The remote path is the configured outbound directory plus the filename. On a successful drop the filename is stored in supplier_dispatches.reference. The outbound 850 itself is not written to vendor_documents — that table is for inbound supplier documents, and document_type has no purchase_order member.

The SFTP connection pattern

ssh2-sftp-client v12, connect-per-job, no pooling. withSftp() builds a new client, connects, runs the callback, and ends the session in a finally:

export async function withSftp<T>(
fn: (sftp: SftpClient) => Promise<T>,
overrides: SftpConnectionOverrides = {},
): Promise<T> {
const config = resolveSftpConfig(overrides);
// Throws before any socket is opened when the credential is unusable.
const options = buildConnectOptions(config);
const log = componentLogger('sftp');

const sftp = new SftpClientImpl('sysco-sftp', {
error: (err: Error) => log.warn({ err }, 'sftp global error event'),
end: () => log.debug('sftp connection ended'),
close: () => log.debug('sftp connection closed'),
});

try {
await sftp.connect(options);
return await fn(sftp);
} finally {
try {
await sftp.end();
} catch (err) {
log.warn({ err }, 'failed to close sftp connection cleanly');
}
}
}

The Sysco dispatch runs ensureDir() and putFile() inside one withSftp call, so a job needs one session for the whole job rather than one per operation.

ChoiceReason
A new client per withSftp callThe library removed automatic connection retries entirely; every connect() is a single-attempt operation scoped to one job. A test asserts a fresh client per call
No retry logic in the adapter at allBullMQ's attempts/backoff is the sole retry mechanism — see the table below. A test asserts connect() is never retried in-process
end() in finally, unconditionallyA skipped end() leaks a file descriptor and a socket per job. Tests assert the connection closes when the callback resolves and when it throws, and that many sequential sessions leak no handles
Private key decoded and validated before connectingAn empty key, base64 that decodes to zero bytes, or bytes with no PRIVATE KEY header all raise SftpConfigError before a socket opens — an empty key used to leave the connect promise unsettled forever
Password auth only as a fallbackIt exists solely for the local atmoz/sftp simulator and the in-process test server. Sysco is key-only, and when neither credential is present the call fails immediately instead of burning the readyTimeout
readyTimeout from SYSCO_SFTP_READY_TIMEOUT_MSBounds the SSH handshake; defaults to 20000 ms
keepaliveInterval: 0Irrelevant for a connection that lives for one job; keep-alive only matters for persistent sessions, which are explicitly not used
Event callbacks passed to the constructorReplaces the library's default console.* handlers with structured pino logs
for...of with await for sequential downloads.forEach with an async callback does not follow the promise chain, so requests interleave on a session that cannot take it

Never loop on connect() to test connectivity: repeated connects make the server raise "no more sessions", and a failed connect can still eventually establish a session.

Directories come from suppliers.config, falling back to SYSCO_SFTP_OUTBOUND_DIR (/outbound), SYSCO_SFTP_INBOUND_DIR (/inbound) and SYSCO_SFTP_ARCHIVE_DIR (/archive). resolveSyscoConfig layers the stored JSONB over those process defaults and validates the result, throwing the permanent SupplierConfigError when something mandatory is still missing. It never reads a credential from the database — only the name of the Vault secret, defaulting to SYSCO_SFTP_KEY_B64.

Retries and concurrency

SettingValueSet by
Attempts3DISPATCH_ATTEMPTS, applied in defaultJobOptions()
BackoffExponential from 1000 msDISPATCH_BACKOFF_MS
Completed-job retention1 hour, last 1000 jobsFixed in defaultJobOptions() — not true, so dedup state is not freed early
Failed-job retention24 hoursFixed, so a failed dispatch stays available for audit and manual retry
Queue key prefixlucilleQUEUE_PREFIX
supplier-dispatch concurrency1Hardcoded in worker.ts
supplier-dispatch lock duration120 sHardcoded — long enough for a Chromium render plus an upload plus SMTP
backorder-reroute concurrency4EMAIL_WORKER_CONCURRENCY

The dispatch concurrency of 1 is the direct consequence of Sysco's SSH session limit: the Sysco lane must be serialised or it trips "no more sessions". The tidy fix — a dedicated Sysco queue chosen by the producer — was not available, because enqueueSupplierDispatch always writes to the single supplier-dispatch queue, and the alternative of two workers on one queue deferring each other's jobs with moveToDelayed was rejected: BullMQ has no server-side filter, so the workers would burn Redis round trips re-fetching each other's jobs and a deferral loop would look exactly like a stuck queue. So one worker at concurrency 1 handles every supplier type, which serialises Costco email and Central Kitchen PDF work that could safely run four-wide. At ~25 dispatches a day that is comfortably fine. If it ever stops being fine, the fix is a dedicated Sysco queue, not a filtering scheme inside the shared one.

SYSCO_WORKER_CONCURRENCY is currently inert

The environment variable exists and is parsed into config.queue.syscoConcurrency (default 1), but worker.ts does not read it — the dispatch worker's concurrency: 1 is a literal, for the reason above. Changing the variable has no effect today. Verified by grep across apps/ and packages/.

The outbound dispatch path

processSupplierDispatch in handlers/supplier-dispatch.ts owns everything that is not supplier-specific; dispatchToSysco owns the 850 and the file drop and touches no table. In order, one job execution:

  1. Validates the payload against supplierDispatchJobSchema. A shape error is the API's bug, so the job is discard()ed and fails permanently rather than burning three attempts.
  2. Loads the supplier_dispatches row with its supplier, order, restaurant and line-item links in one round trip. A missing row is permanent — there is nothing a retry could fix.
  3. Checks the dispatch status and returns early when it is already submitted or confirmed. This is the worker-level Postgres idempotency guard, and it is the load-bearing one: BullMQ's deduplication prevents duplicate enqueues, not duplicate executions. A crashed pod's job is re-queued by the stall detector on a path that bypasses deduplication entirely, and the dedup key is released as soon as a job reaches a terminal state. An SFTP drop is not reversible, so the guard is what makes re-execution harmless.
  4. Calls dispatchToSysco, which allocates the control numbers, serialises the 850, then opens one withSftp session to ensureDir the outbound directory and putFile the interchange.
  5. Marks it submitted. The dispatch row (status, reference, submittedAt, attempt, failureReason: null) and its line items (submitted) move in one transaction, so a dispatch is never submitted while its lines still say pending. SUPPLIER_SUBMITTED and the order roll-up are written after the commit, because losing an audit row is bad but failing a dispatch that physically happened is worse.

The SUPPLIER_SUBMITTED payload carries reference, remotePath, segmentCount, lineCount and controlNumbers — the last of those exists specifically so an inbound 997, which carries no PO number, can be correlated back to this dispatch.

Failures are classified. SupplierConfigError, SftpConfigError, a ZodError, a dispatch whose payload shares no line items with its row, an inactive supplier and anything carrying permanent === true are permanent: the job is discarded, the dispatch goes straight to failed, its line items to dispatch_failed, SUPPLIER_DISPATCH_FAILED is appended and an admin alert email goes out. Everything else — a refused connection, an auth failure, a timeout — is retryable, and the row records the attempt number and failure reason while the retry budget lasts. Note that a missing SFTP credential is permanent by design: three attempts will not conjure a private key.

Because each attempt allocates a fresh interchange control number, a retried dispatch sends a new ISA13. That is correct — a repeated ISA13 is what makes a partner silently discard a file as a duplicate — and it is why gaps in the sequence are expected.

Inbound: X12 855 Purchase Order Acknowledgment

Separators are detected from the fixed-width ISA rather than assumed: the element separator is the character at offset 3, the component separator is ISA16 at offset 104, and the segment terminator is whatever follows it at offset 105. The reader then cross-checks that the element separator really appears at all sixteen fixed ISA boundaries, so a file that is not fixed-width is rejected instead of mis-parsed. A newline is a legal terminator and is accepted; apps/worker/tests/fixtures/855-pipe-separators.edi is a real 855 with | elements, ^ components and newline terminators and parses identically.

This is apps/worker/tests/fixtures/855-split-disposition.edi, again wrapped for readability:

ISA*00* *00* *ZZ*SYSCO *ZZ*LUCILLE *260814*0930*U*00401*000000103*0*T*>~
GS*PR*SYSCO*LUCILLE*20260814*0930*103*X*004010~
ST*855*0001~
BAK*00*AC*PO-1003*20260813~
PO1*1*10*CA***VN*SYS-10045~
ACK*IA*6*CA*068*20260816~
ACK*IB*4*CA*068*20260901~
CTT*1~
SE*7*0001~
GE*1*103~
IEA*1*000000103~

The parser never throws. Success is { ok: true, … }; anything unreadable is { ok: false, error, rawSegments }, where rawSegments is a best-effort split so the raw file can still be recorded. Transaction sets are selected on ST01 = 855; GS01 is read and recorded but not validated, so the PR above is informational.

BAK is read for BAK01 (purpose code), BAK02 (acknowledgment type — AT accepted, AC acknowledged with changes, AD acknowledged with detail and change, RJ rejected), BAK03 (the original PO number, which is the main correlation key), BAK04 (PO date) and BAK08 (the supplier's own acknowledgment number). BAK02 is stored verbatim and surfaced on the SUPPLIER_CONFIRMED audit event; the parser does not cross-check it against the line codes, so a header saying AT over a line saying IB produces no warning. Line dispositions, not BAK02, drive every decision.

ACK01 line item status codes

ACK01 (element 668) is the seller's action on one line. The parser recognises exactly nine codes and maps them to four dispositions:

ACK01MeaningDispositionReroute?
IAItem acceptedconfirmedNo
IBItem back-orderedbackorderedYes
BPItem partially acceptedbackorderedYes
IRItem rejectedrejectedYes
ICItem changedchangedNo
IQAccepted, quantity changedchangedNo
IPAccepted, price changedchangedNo
SPItem substitutedchangedNo
DRItem deleted / cannot shipchangedNo

Any other value is kept verbatim in rawStatusCode, has statusCode = null, raises the warning unrecognised ACK01 status code, and is excluded from the decision — the line is never discarded.

Precedence when one PO1 loop mixes codes, highest first: rejected beats backordered, which beats changed, which beats confirmed. A rejection outranks a back order because nothing is coming at all, and changed outranks confirmed so that a partially altered line still demands attention. A loop whose codes are all unrecognised — or that has no ACK segment at all — resolves to changed, which records the acknowledgment without triggering a duplicate order.

export function classifyAck(codes: readonly AckLineStatusCode[]): AckDisposition {
if (codes.some((code) => REJECTED_ACK_CODES.includes(code))) return 'rejected';
if (codes.some((code) => BACKORDERED_ACK_CODES.includes(code))) return 'backordered';
if (codes.some((code) => CHANGED_ACK_CODES.includes(code))) return 'changed';
if (codes.some((code) => CONFIRMED_ACK_CODES.includes(code))) return 'confirmed';
return 'changed';
}
Known documentation defect in packages/types

The JSDoc above ackLineStatusCodeSchema in packages/types/src/enums.ts says "IB, BP, IR, DR and SP are the codes that trigger the backorder reroute workflow". That comment is stale. The implementation classifies DR and SP as changed — recorded, never rerouted — which is what the technical plan specifies and what apps/worker/tests/edi/parse-855.test.ts asserts. Only IB, BP and IR reroute. The behaviour documented on this page is authoritative; the comment in the types package is a defect to be fixed there.

Split ACK quantities

A single PO1 loop may carry several ACK segments — one per status-and-quantity pair — and the fixture above is exactly that: ACK*IA*6 and ACK*IB*4 against PO102 = 10. Two rules follow:

  1. Every ACK in the loop is parsed before anything is decided. Acting on the first would see IA, mark the line confirmed and lose the shortfall entirely.
  2. The sum of ACK02 should equal PO102 — but a mismatch is a warning, not an error. The quantities are compared with a tolerance of 0.001 to survive decimal pack sizes, and the document is still parsed, persisted and acted on. 855-quantity-mismatch.edi acknowledges 6 of 10 and yields the single warning ACK02 quantities sum to 6 but PO102 is 10.

The reroute is enqueued for the whole line, not for the shortfall: backorderRerouteJobSchema carries orderId, lineItemId, originalSupplierId, an optional alternateSupplierId, an optional reason and the optional ackCode — there is no quantity field. Duplicate reroutes are prevented three ways: a per-line check for an existing LINE_ITEM_BACKORDERED audit row for the same (lineItemId, originalSupplierId) pair (possible only because audit_events is append-only, decision D6), BullMQ deduplication on reroute:{lineItemId}:{originalSupplierId}, and idempotency on vendor_documents.reference.

Applying a parsed 855 sets each matched line to confirmed or backordered, appends LINE_ITEM_BACKORDERED for the latter, and marks the dispatch confirmed only once every line it carried is confirmed. Lines are correlated by PO107 against the supplier SKU first, falling back to PO101 as an index into the dispatch's lines in creation order.

Inbound: 997 Functional Acknowledgment

Sysco's gateway returns a 997 for every interchange it receives. A rejected interchange with no 997 handler is a completely silent failure — the order simply never exists at Sysco.

ISA*00* *00* *ZZ*SYSCO *ZZ*LUCILLE *260814*1410*U*00401*000000202*0*T*>~
GS*FA*SYSCO*LUCILLE*20260813*1410*202*X*004010~
ST*997*0001~
AK1*PO*8~
AK2*850*0002~
AK3*PO1*9**8~
AK4*3*355*7*CSE~
AK5*E*5~
AK9*E*1*1*0~
SE*8*0001~
GE*1*202~
IEA*1*000000202~

Rejection is much broader than "AK501 is E or R". isRejected() is true when any of the following holds:

  • the document could not be parsed (ok: false), including junk, an empty payload and a truncated ISA;
  • the interchange parses but contains no 997 transaction set;
  • it carries no AK5/AK9 acknowledgment code at all — absence of evidence is not acceptance;
  • any AK501 or AK901 code, compared case-insensitively, is not A.
CodeMeaningTreated as
AAcceptedAccepted
EAccepted, but EDI compliance errors were notedRejection
PPartially accepted — at least one transaction rejectedRejection
RRejectedRejection
MRejected — message authentication code failedRejection
WRejected — assurance failed validity testsRejection
XRejected — content after decryption could not be analysedRejection

Any code outside that table is also a rejection; rejectionSummary() renders it as unknown code rather than guessing. AK5 and AK9 are treated identically — a group-level AK901 of E rejects the interchange even if every AK501 says A.

rejectionSummary() builds one human-readable string per failing group and transaction set, including the AK502+/AK905+ syntax error codes and every AK3 segment error with its AK4 element errors (element position, reference number, syntax error code and the offending value). assertAccepted() throws Ack997RejectedError with that summary, so a handler cannot ignore a rejection by forgetting to branch.

A rejection is not just recorded. The dispatch is set to failed with the summary as its failureReason, its line items move to dispatch_failed (nothing was actually ordered), an EDI_REJECTED audit event is appended, the order status is recomputed, and the error goes to Sentry. An accepted 997 changes no state at all — it is recorded and logged, because line-level confirmation is the 855's job.

A 997 carries no PO number and no filename, so it is correlated by AK102 (our GS06) against the controlNumbers.group value recorded in the dispatch's SUPPLIER_SUBMITTED audit event, with an order-number-from-filename fallback.

Storage of inbound documents

Every inbound file is written to vendor_documents with its raw content, parsed or not. document_type is one of confirmation, invoice or backorder.

Situationdocument_typedispatch_idparsed_dataAlert
855, every line acceptedconfirmationcorrelatedpopulatednone
855 with any back-ordered or rejected linebackordercorrelatedpopulatednone
997 accepted (A everywhere)confirmationcorrelatedpopulatednone
997 rejected (any non-A, or no code)confirmationcorrelatedpopulatedSentry
Unparseable fileconfirmationnullnull, with parse_error setSentry
Parsed but references an unknown POas abovenullpopulatedSentry

Keeping the raw interchange is what makes an EDI problem diagnosable at all. A dropped file is not. Reprocessing is idempotent: processInboundEdi keys on vendor_documents.reference — the supplied filename, or a content hash when none is given — so replaying a file reports alreadyProcessed: true and changes nothing.

The transaction set is sniffed from ST01. When that is unreadable both parsers are tried before giving up, and a file neither recognises is recorded as a failed 855, which is the far more common inbound document.

The inbound poll CronJob

apps/worker/src/jobs/sysco-poll.ts is the sysco-inbound-poll entrypoint, run as node apps/worker/dist/jobs/sysco-poll.js. The schedule lives in the infrastructure definitions, not in code: cronSchedule: "*/15 * * * *" in infra/dev/lucille-sysco-poll.yaml and its prod twin, using the regular-cronjob template, which fixes concurrencyPolicy: Forbid, backoffLimit: 2 and activeDeadlineSeconds: 600 — polls must never overlap.

One cycle, per active supplier of type sysco, inside a single withSftp session:

  1. List the inbound directory. Entries that are not files are skipped, as are names starting with . or ending in .tmp or .part — an in-progress upload is not ours to read.
  2. Download and apply, one file at a time, with for...of and await — never .forEach with an async callback, which would interleave requests on a session that cannot take it and race the archive step. Each file is read into memory and passed to processInboundEdi with supplierId, reference set to the file name, actorId: null and the real X12 parsers.
  3. Archive, but only after processing succeeded. A successfully parsed file moves to the archive directory; an unparseable one moves to {archiveDir}/failed/ instead, so garbage that will never parse cannot be re-listed every fifteen minutes forever while still being available to a human. If the target directory cannot even be created, the file is deliberately left in place and a warning logged — repeating a harmless list entry beats deleting evidence.
  4. Alert on an unparseable file: it is already persisted in vendor_documents with parse_error set and reported to Sentry, and the poll additionally emails an admin naming the file, whether it was archived, the parser warnings and the reprocess instructions.

A file whose processing threw — a database or configuration fault rather than a bad file — is left in the inbound directory untouched and retried next cycle. That is what makes "no data loss: files stay on Sysco's server until successfully processed" literally true.

The CronJob calls processInboundEdi directly rather than posting to the HTTP route. It registers the worker's parsers into the API's domain layer with setEdiParsers first, because the dependency only runs one way (@lucille/worker depends on @lucille/api), which also means the CronJob exercises exactly the parsers the unit tests do.

Exit codes matter for a CronJob: 1 when an SFTP connect or listing failed, and 1 when a non-empty directory yielded nothing processed or skipped — a systemic problem a green run would hide. A single bad file among good ones exits 0, because it is already in Sentry and the admin mailbox. Either way Prisma, Redis, the queues and telemetry are closed in a finally; a CronJob that does not terminate blocks the next scheduled run under concurrencyPolicy: Forbid.

POST /api/inbound/sysco (admin only) accepts { content, filename? } and applies the same processInboundEdi. It is the reprocess/replay entrypoint, not the path the CronJob uses: there is no Zitadel machine-to-machine credential in this deployment, and the CronJob runs from the same image with the same database credentials, so going over HTTP would add a network hop, a second failure mode and an auth problem to a call that is already local.

Test coverage

Based on a fresh listing of apps/worker/tests/: the 850 serialiser (including the byte-for-byte golden file), the low-level X12 writer, both inbound parsers, control-number allocation against a live PostgreSQL instance, the SFTP adapter against a real in-process SSH server, and the Sysco dispatch handler end to end — tests/handlers/sysco.test.ts seeds an order, runs processSupplierDispatch, and asserts on the bytes that landed in the outbound directory plus the dispatch row, line-item statuses, order roll-up and the SUPPLIER_SUBMITTED audit payload, with nothing mocked.

Not covered by a test in this app at the time of writing: jobs/sysco-poll.ts (no poll test exists), the BullMQ wiring in worker.ts, and the backorder-reroute handler. The supplier-dispatch handler is exercised only along its Sysco path. Treat the poll behaviour described above as read from source rather than as pinned by a test.

One further deliberate deviation from the plan is worth recording here: decision D8 pins node-x12@1.7.1 as a dependency, but the 850 writer and the 855/997 parsers are implemented directly against the ANSI 004010 spec in apps/worker/src/edi/. The library has been in maintenance mode since 2021, and fixed-width ISA handling, SE01 counting and inbound separator detection are precisely the risky parts — owning them explicitly beats wrapping a stale dependency. The pin stays so it can be swapped in later.

Where to go next