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.
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 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.
| Segment | Emitted | Elements the serialiser populates |
|---|---|---|
ISA | Always | Sixteen fixed-width elements, exactly 106 characters including the terminator. ISA01/ISA03 = 00, ISA02/ISA04 blank, ISA05–ISA08 from config, ISA09 YYMMDD UTC, ISA10 HHMM UTC, ISA11 U, ISA12 derived from the EDI version (004010 → 00401), ISA13 nine digits zero-padded, ISA14 0 (no TA1 requested), ISA15 T unless configured P, ISA16 the component separator |
GS | Always | GS01 = 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 |
ST | Always | ST01 = 850; ST02 the transaction control number zero-padded to four digits, reused verbatim as SE02 |
BEG | Always | BEG01 = 00 (Original), BEG02 = SA (Stand-alone Order), BEG03 the order number, BEG04 deliberately empty, BEG05 the document date |
REF | Always | REF01 = ST (Store Number), REF02 the restaurant code. Exactly one REF; no other qualifier is written |
DTM | Always, exactly one | DTM*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 loop | Only with a location | N1*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 |
PO1 | One per line item | PO101 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 |
PID | One per line item | PID*F****<description> — PID01 = F, PID02–PID04 empty, PID05 the item name plus unit size, truncated to 80 characters |
CTT | Always | CTT01 = the number of PO1 segments. CTT02 (hash total of PO102) is not emitted |
SE | Always | SE01 counted from ST through SE inclusive; SE02 = ST02 |
GE | Always | GE01 the transaction-set count, GE02 = GS06 |
IEA | Always | IEA01 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.
| Choice | Reason |
|---|---|
A new client per withSftp call | The 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 all | BullMQ's attempts/backoff is the sole retry mechanism — see the table below. A test asserts connect() is never retried in-process |
end() in finally, unconditionally | A 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 connecting | An 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 fallback | It 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_MS | Bounds the SSH handshake; defaults to 20000 ms |
keepaliveInterval: 0 | Irrelevant 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 constructor | Replaces 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
| Setting | Value | Set by |
|---|---|---|
| Attempts | 3 | DISPATCH_ATTEMPTS, applied in defaultJobOptions() |
| Backoff | Exponential from 1000 ms | DISPATCH_BACKOFF_MS |
| Completed-job retention | 1 hour, last 1000 jobs | Fixed in defaultJobOptions() — not true, so dedup state is not freed early |
| Failed-job retention | 24 hours | Fixed, so a failed dispatch stays available for audit and manual retry |
| Queue key prefix | lucille | QUEUE_PREFIX |
supplier-dispatch concurrency | 1 | Hardcoded in worker.ts |
supplier-dispatch lock duration | 120 s | Hardcoded — long enough for a Chromium render plus an upload plus SMTP |
backorder-reroute concurrency | 4 | EMAIL_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 inertThe 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:
- Validates the payload against
supplierDispatchJobSchema. A shape error is the API's bug, so the job isdiscard()ed and fails permanently rather than burning three attempts. - Loads the
supplier_dispatchesrow 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. - Checks the dispatch status and returns early when it is already
submittedorconfirmed. 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. - Calls
dispatchToSysco, which allocates the control numbers, serialises the 850, then opens onewithSftpsession toensureDirthe outbound directory andputFilethe interchange. - 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 neversubmittedwhile its lines still saypending.SUPPLIER_SUBMITTEDand 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:
ACK01 | Meaning | Disposition | Reroute? |
|---|---|---|---|
IA | Item accepted | confirmed | No |
IB | Item back-ordered | backordered | Yes |
BP | Item partially accepted | backordered | Yes |
IR | Item rejected | rejected | Yes |
IC | Item changed | changed | No |
IQ | Accepted, quantity changed | changed | No |
IP | Accepted, price changed | changed | No |
SP | Item substituted | changed | No |
DR | Item deleted / cannot ship | changed | No |
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';
}
packages/typesThe 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:
- Every
ACKin the loop is parsed before anything is decided. Acting on the first would seeIA, mark the line confirmed and lose the shortfall entirely. - The sum of
ACK02should equalPO102— but a mismatch is a warning, not an error. The quantities are compared with a tolerance of0.001to survive decimal pack sizes, and the document is still parsed, persisted and acted on.855-quantity-mismatch.ediacknowledges 6 of 10 and yields the single warningACK02 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 truncatedISA; - the interchange parses but contains no
997transaction set; - it carries no
AK5/AK9acknowledgment code at all — absence of evidence is not acceptance; - any
AK501orAK901code, compared case-insensitively, is notA.
| Code | Meaning | Treated as |
|---|---|---|
A | Accepted | Accepted |
E | Accepted, but EDI compliance errors were noted | Rejection |
P | Partially accepted — at least one transaction rejected | Rejection |
R | Rejected | Rejection |
M | Rejected — message authentication code failed | Rejection |
W | Rejected — assurance failed validity tests | Rejection |
X | Rejected — content after decryption could not be analysed | Rejection |
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.
| Situation | document_type | dispatch_id | parsed_data | Alert |
|---|---|---|---|---|
| 855, every line accepted | confirmation | correlated | populated | none |
| 855 with any back-ordered or rejected line | backorder | correlated | populated | none |
997 accepted (A everywhere) | confirmation | correlated | populated | none |
997 rejected (any non-A, or no code) | confirmation | correlated | populated | Sentry |
| Unparseable file | confirmation | null | null, with parse_error set | Sentry |
| Parsed but references an unknown PO | as above | null | populated | Sentry |
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:
- List the inbound directory. Entries that are not files are skipped, as are names starting with
.or ending in.tmpor.part— an in-progress upload is not ours to read. - Download and apply, one file at a time, with
for...ofandawait— never.forEachwith 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 toprocessInboundEdiwithsupplierId,referenceset to the file name,actorId: nulland the real X12 parsers. - 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. - Alert on an unparseable file: it is already persisted in
vendor_documentswithparse_errorset 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
- Supplier integrations — how the three suppliers compare.
- Queues and jobs — retry, deduplication and the inbound poll.
- Data model —
edi_control_numbersandvendor_documents. - Troubleshooting — "no more sessions" and 997 rejections.