Hardens Qqq.PrintAgent.Core at its boundaries, makes it fully testable, and brings it to 100% line coverage (2,246 of 2,246 lines; 793 tests, up from 200), gated at 100.
What the server sends is validated where it arrives
A claimed job is checked at the claim (PrintJobContract): identifiers, title, format, hash, length, copies, scale, paper and media. A job that breaks the contract but can be identified is refused (RECEIVED, then FAILED with fixed text, "no bytes were submitted") and the lane goes on to the next job. Before, such a job either threw part-way or stopped the device's whole queue until someone cancelled it on the server.
Fields the agent does not read are ignored at any depth; only a repeated member the agent reads is a violation. A claim is parsed to 1,024 levels, the most the server's 2,000-character options cap can produce, so every job a submitter can create is identifiable. A byte-order mark is accepted.
Realtime frames are read by RealtimeEnvelope; ACK, ERROR and COMMAND payloads are validated; a violation is one exception type (PrintingProtocolException).
A malformed replay of a job the journal already knows changes nothing. A claimed job that is already SUBMITTING is skipped and logged, never submitted twice.
The print boundary is tested at its edges and one outcome is corrected
SUBMITTING is still recorded before Windows is asked, and an interrupted submission is still OUTCOME_UNKNOWN. New: a job already recorded SPOOLED stays SPOOLED when releasing its resources fails afterwards (it was overwritten with OUTCOME_UNKNOWN).
A worker that stages documents to files refuses to be built on a backend that cannot take files.
A typed compatibility check for the wizard
QqqSessionClient.CheckCompatibilityAsync returns stages (connection, security, server, printing, sign-in), the problems found, the sign-in methods offered and, for a refused certificate, what was presented. It sends no credential and never ends a held session (a 401 to its public requests used to clear the session).
Server-supplied text reaches a message or a log line only in printable, bounded form.
Seams
Every limit runs on an injected TimeProvider (except HttpClient.Timeout); HTTP and WebSocket transports sit behind IQqqTransport and IQqqSocketConnector; four platform adapters are excluded from coverage with a written reason.
PrintingRealtimeChannel.RunAsync returns when it was ended on purpose and throws when it ended by itself; a send overtaken by the close is told the channel closed.
Docs: docs/REALTIME.md (jobs the worker does not print), docs/security/threat-model.md (claim boundary, compatibility check), CHANGELOG.md.
Verification, local, at this head on f78943e: build/Verify.ps1 passed twice with no failing test: 793 Core and 604 Windows/CLI tests, zero warnings; Core 100%, command line 100%, Windows 70.15%. The same branch passed three times before the last four commits.
Not verified: no real server (in particular that it accepts RECEIVED then FAILED for a refused job and stops offering it), no installed service, no physical printing.
Review: independent, four passes. Pass one found a blocker (a contract-valid job that stalled the queue) and nine should-fix items; pass two found two; pass three found one (the parse depth did not cover array nesting); each was fixed as proposed. The reviewer confirmed the replay onto the release branch as faithful and the channel change as invisible to the service.
Known, recorded for the Windows layer: a replay of a job another lane holds is stopped only by an accidental check; a printer unshared while jobs queue marks them OUTCOME_UNKNOWN although Windows was never asked; the journal cuts a long title by UTF-16 units.
Hardens `Qqq.PrintAgent.Core` at its boundaries, makes it fully testable, and brings it to 100% line coverage (2,246 of 2,246 lines; 793 tests, up from 200), gated at 100.
**What the server sends is validated where it arrives**
- A claimed job is checked at the claim (`PrintJobContract`): identifiers, title, format, hash, length, copies, scale, paper and media. A job that breaks the contract but can be identified is refused (RECEIVED, then FAILED with fixed text, "no bytes were submitted") and the lane goes on to the next job. Before, such a job either threw part-way or stopped the device's whole queue until someone cancelled it on the server.
- Fields the agent does not read are ignored at any depth; only a repeated member the agent reads is a violation. A claim is parsed to 1,024 levels, the most the server's 2,000-character options cap can produce, so every job a submitter can create is identifiable. A byte-order mark is accepted.
- Realtime frames are read by `RealtimeEnvelope`; ACK, ERROR and COMMAND payloads are validated; a violation is one exception type (`PrintingProtocolException`).
- A malformed replay of a job the journal already knows changes nothing. A claimed job that is already SUBMITTING is skipped and logged, never submitted twice.
**The print boundary is tested at its edges and one outcome is corrected**
- SUBMITTING is still recorded before Windows is asked, and an interrupted submission is still OUTCOME_UNKNOWN. New: a job already recorded SPOOLED stays SPOOLED when releasing its resources fails afterwards (it was overwritten with OUTCOME_UNKNOWN).
- A worker that stages documents to files refuses to be built on a backend that cannot take files.
**A typed compatibility check for the wizard**
- `QqqSessionClient.CheckCompatibilityAsync` returns stages (connection, security, server, printing, sign-in), the problems found, the sign-in methods offered and, for a refused certificate, what was presented. It sends no credential and never ends a held session (a 401 to its public requests used to clear the session).
- Server-supplied text reaches a message or a log line only in printable, bounded form.
**Seams**
- Every limit runs on an injected `TimeProvider` (except `HttpClient.Timeout`); HTTP and WebSocket transports sit behind `IQqqTransport` and `IQqqSocketConnector`; four platform adapters are excluded from coverage with a written reason.
- `PrintingRealtimeChannel.RunAsync` returns when it was ended on purpose and throws when it ended by itself; a send overtaken by the close is told the channel closed.
**Docs**: `docs/REALTIME.md` (jobs the worker does not print), `docs/security/threat-model.md` (claim boundary, compatibility check), `CHANGELOG.md`.
**Verification**, local, at this head on `f78943e`: `build/Verify.ps1` passed twice with no failing test: 793 Core and 604 Windows/CLI tests, zero warnings; Core 100%, command line 100%, Windows 70.15%. The same branch passed three times before the last four commits.
**Not verified**: no real server (in particular that it accepts RECEIVED then FAILED for a refused job and stops offering it), no installed service, no physical printing.
**Review**: independent, four passes. Pass one found a blocker (a contract-valid job that stalled the queue) and nine should-fix items; pass two found two; pass three found one (the parse depth did not cover array nesting); each was fixed as proposed. The reviewer confirmed the replay onto the release branch as faithful and the channel change as invisible to the service.
**Known, recorded for the Windows layer**: a replay of a job another lane holds is stopped only by an accidental check; a printer unshared while jobs queue marks them OUTCOME_UNKNOWN although Windows was never asked; the journal cuts a long title by UTF-16 units.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The reasons a server is or is not usable existed only as log text and one
fixed exception message. CheckCompatibilityAsync makes the same two public
requests every sign-in starts with and returns a QqqServerCompatibility: one
problem per shortfall with its kind and stage (unreachable, secure connection
failed with the refused certificate, not a QQQ server, API version, feature or
channel missing, printing extension missing, too old or unsupported, no usable
sign-in), the discovery document and the sign-in methods on offer. The
connection wizard can show which step failed without parsing a message.
DiscoverAsync keeps its contract: discovery and the native grant are judged by
the same rules through one shared list of problems, and it still throws the
generic configuration failure.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The acknowledgement wait in PrintingRealtimeChannel, the session client's
twenty second request limit, the control poll and receipt limits, the three
download limits, the per-download bandwidth pacing and the worker's phase
timings read the wall clock or called CancelAfter and Task.Delay directly, so
none of them could be tested without waiting.
They now go through a TimeProvider: PrintingRealtimeChannel, PrintApiClient
(and ForSession) and PrintWorkerOptions take one, defaulting to
TimeProvider.System, and QqqSessionClient uses the one it already accepted.
Deadline wraps the timed-cancellation pattern in one place.
ValidateRealtimeCommand no longer needs the current time at all.
Tests move a hand-written clock and check each limit fires at its configured
time and not a millisecond before.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A claimed job was returned as the server sent it, so one with no options
reached PrintWorker and failed on a null reference, and a malformed realtime
frame surfaced as InvalidDataException, JsonException, KeyNotFoundException or
InvalidOperationException depending on where it broke. ACK and ERROR payloads
were read unguarded.
Everything the server sends now passes one set of checks and fails with one
type, PrintingProtocolException (an IOException, so existing reconnect and
retry handling is unchanged):
- ClaimAsync and QueuedJobsAsync read through PrintJobContract: bounded depth,
no repeated field, safe identifiers, a hash, a positive length and usable
options. Display-only members that are missing become empty text.
- The channel parses frames through RealtimeEnvelope.Parse, requires an object
payload, and requires ACK to name its message and ERROR to carry a code.
- ValidateRealtimeCommand raises the same type; the HTTP control poll keeps
InvalidDataException, which its callers and tests rely on.
- RealtimeEnvelope.ReadPayload gives consumers the same failure for a payload
of the wrong shape.
A claimed job that breaks the contract but names itself is not left
unanswered, because the server would offer it again forever and block the
queue behind it: the worker records it RECEIVED then FAILED with the reason,
exactly the sequence the null reference used to produce, and a lane keeps
draining. A job that cannot be identified still stops the claim.
Replayed onto release/2.0: there a send released by a closing channel fails
with RealtimeChannelClosedException carrying what closed it, so the tests
here expect that type and that cause instead of a plain IOException.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Seven session operations logged their failure from an exception filter that
always returned false, leaving a `{ throw; }` body no test could ever reach.
They now catch, log and rethrow, which is the same behaviour with nothing
unreachable.
HttpClientHandler, SocketsHttpHandler and ClientWebSocket were constructed
inline in QqqSessionClient. Construction moves behind IQqqTransport and
IQqqSocketConnector; the production implementations (QqqPlatformTransport,
QqqPlatformSocketConnector) only build platform objects from what the client
hands them and are excluded from coverage with a written reason. The redirect
and cookie policy, both certificate decisions, the upgrade request and the
meaning of every upgrade outcome stay in QqqSessionClient and are now tested
through a fake transport.
Also: the realtime discovery refresh no longer dereferences an empty slot in
the server's extension list, the browser callback checks move to their own
method, and TrafficHttpContent drops an override the platform never calls.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Adds behaviour tests, with hand-written fakes only, for what the Core suite
did not reach: the OUTCOME_UNKNOWN branch (a submission interrupted or
cancelled after SUBMITTING is recorded is never printed again), every refusal
before native submission, each PrintWorkerOptions hook in order, enrollment,
the routes that move onto the realtime channel, the HTTP control path,
operator actions, download refusals, ExposedPrinterBackend, the
IFilePrinterBackend default, staged-file cleanup, the settings parser, the
channel's own limits, OwnedPrintingSocket, TrafficStream and TrafficHttpContent.
Small production changes the tests called for:
- PrintWorker settles whether the backend can take a staged file before it
records SUBMITTING, so that mismatch is FAILED, not OUTCOME_UNKNOWN.
- PrintWorker never claims a job the journal already shows as SUBMITTING,
even when it was told not to recover the journal itself.
- The channel reader stops on cancellation explicitly at the top of each
iteration instead of through a loop condition.
- PrintingSessionHandler becomes a type of its own so its route confinement
can be tested directly.
- AgentConfiguration, EnrollmentRequest and EnrollmentResponse print as
redacted labels; their generated ToString would have printed the device
token or enrollment code.
- Deadline loses a member nothing used.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Adds tests for the session client's edges: origins it will not be bound to,
sign-in input it will not send, sessions it will not adopt, a printing
response that tries to replace the session, a body that outgrows its bound
without announcing a length, discovery of the wrong shape, every native grant
or provider it will not open a browser for, every callback it will not
exchange, the credential types' input rules, capability versions, and the
whole provider sign-out flow (the request it builds, each reason there is
nothing to open, each mismatch it refuses).
Production changes the tests called for:
- BindDeviceAsync loses a device comparison that could never be true: the
identity check before it already refuses, and clears the session, when the
server names another device.
- The printing route's syntax check and its check of the resulting address
become one refusal, so the second no longer has a line no input can reach.
- SendCoreAsync is internal so the origin guard on the client's single send
path can be tested directly.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
MachineStateDirectory could only be exercised against the real ProgramData
folder, and only by an elevated process that owns a folder as Administrators.
Its rules move to MachineStateGuard, which is given the folder to guard and an
IMachineStatePlatform for the Windows facts it judges (process token, file
attributes, security descriptors, protected folder creation). The static class
remains the fixed entry point for the real folder with the same members; the
production platform is a set of single Windows calls, excluded from coverage
with a written reason.
RuntimeBranding.LoadMachine gains an overload that takes the branding file and
a reader for the installer's persisted labels; the registry read moves to
MachineInstallerLabels, likewise excluded. AppIdentity composes its identity
through Compose and finds an edition's logo through EditionLogoIn, so both can
be tested with an edition and branding of the test's own.
Tests cover the guard's rules with chosen owners, access rules and links in a
temporary folder, the installer labels, icon and logo validation, unusable
edition descriptors and the identity layering, none of them reading the
machine's ProgramData folder or registry.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
PrintWorker spelled RECEIVED, SUBMITTING, SPOOLED, FAILED and OUTCOME_UNKNOWN
as string literals in a dozen places, and the rule for which states end a job
as a pattern beside them. PrintJobState holds the wire names and that rule;
the worker and the claim reader use it. A test ties the names to the status
values in the shared protocol fixtures.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Core's merged line coverage is 99.95% (2160 of 2161 lines) with the tests
added on this branch. By the rule in ADR-0003 the minimum is the measured
value rounded down, less one point: 98. The one line still uncovered is the
closing brace of PrintingRealtimeChannel.RunAsync, which is only reached if
the method returns normally, and it always ends by rethrowing why the reader
stopped.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A claim or queued-job response was rejected whole when it nested deeper
than 16 levels or repeated any field name, ignoring case, anywhere in it.
That happened before the job's identifier was read, so the job was never
answered: the server offered it again on every claim and the device printed
nothing until someone cancelled that job on the server. But PrintOptions
allows additional properties, consumers must ignore unknown response
fields, and the server relays a submitter's options unchanged, so such a
response is legitimate.
The claim and queued readers now ignore fields the agent does not read at
any depth the JSON reader accepts (64). The repeated-name rule covers only
the members the agent reads (job, options, media selection), where two
spellings would let it act on a value the server did not mean; a job that
breaks it is refused with a stand-in, so the worker records RECEIVED and
FAILED for it and the lane goes on to the next job. The stand-in is built
from the members as the contract spells them, before any check, so every
violation of an identifiable job names it. Only a response that names no
single job (not JSON, no object, no usable identifier, the identifier or
the job given twice) still stops the lane, as it did before the boundary
existed. A UTF-8 byte-order mark in front of either response is accepted
again, and a root member spelled in another case once is read again.
In the queued list, which is only displayed, the repeated-name rule covers
the two identifiers alone, so one odd job cannot cost the whole preview.
Realtime frames keep the strict rule: depth 24, no repeated name anywhere.
The tests that pinned the stall (the repeated jobId row, the repeated job
member row and the 16-level row) are replaced by tests of this behavior,
including that such a job is refused exactly once and the next is claimed.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The refusal path checked only for a final state. A malformed replay of a
job recorded as RECEIVED or SUBMITTING, which another lane is working on at
that moment, was tracked again, overwriting its title and printer with the
stand-in's blanks, and recorded FAILED "No bytes were submitted" while a
submission could be under way.
A job the journal knows in any state is now left exactly as it is: nothing
is tracked, recorded or reported, and one log line names the job, its
recorded state and the fixed reason. The lane ends its pass, as it does for
a well-formed replay, because the server would only offer the job again.
The test covers RECEIVED, SUBMITTING and each final state.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A lane that was offered a job the journal holds as SUBMITTING dropped it
without a word and ended its pass. Dropping it is right: the job was handed
to a printer once and has not concluded, so it must never be submitted a
second time. It now logs the job, the recorded state and the reason, once
per occurrence.
Ending the pass stays as it is. AgentRuntime.DrainLaneAsync marks the wave
drained when RunOnceAsync returns false, and that is what must happen here:
the server offers a claimed job again until it is answered for, so a lane
that kept claiming would be handed the same job at once and spin without a
pause. The next committed notification or released capacity starts a new
wave, by which time the lane submitting the job, or recovery, has reported
its outcome. The test now asserts that exactly one claim was made.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The worker checked in the middle of a job whether its backend takes files,
and recorded FAILED before SUBMITTING when it did not. The Windows runtime
always passes ExposedPrinterBackend over ProfiledPrinterBackend, which
implement IFilePrinterBackend unconditionally, so that branch could not
occur in production, and its test proved a composition production never
uses.
The requirement is now a construction-time one: a worker whose options name
a staging directory is refused with an ArgumentException naming the
printers parameter unless its backend takes files. The mid-job check and
its test (StagedJobWithABackendThatCannotTakeFilesFailsBeforeSubmitting)
are removed; a construction test replaces it. Both construction sites in
the Windows runtime already compose: the drain lane passes
ExposedPrinterBackend, and the legacy poll stages nothing.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The schema's maxLength for a title counts Unicode code points; the claim
check counted UTF-16 units, so a title of 513 to 1,024 characters from
outside the basic plane was refused although the contract allows it. The
title, and the title a stand-in may keep, are now measured in code points.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two tests added on this branch held a raw U+FEFF and raw characters from
outside the basic plane in their string literals. They are written as
and \U0001F5A8 escapes now, so the source shows what is being fed in.
No test changes what it checks.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The sign-in problems put raw server text into QqqCompatibilityProblem.Detail:
the authentication type, scopes, grant flow, challenge method, prompt, nonce
policy, issuer, discovery address and every invalid redirect address. A
wizard displays that detail before anyone has signed in, so a server could
inject lines into it, reorder it or flood it. Every one of those values now
goes through Printable, and a detail is cut at 512 UTF-16 units whatever was
joined into it; the cut is enforced by the problem type itself.
Printable let U+2028, U+2029 and the bidirectional override and isolate
characters through and could cut a surrogate pair in half at its limit. It
now reads whole code points, turns every control, format, line-separator,
paragraph-separator, private-use and unassigned code point into a space,
cuts only between code points, and stops reading at the limit.
QqqApiDiscovery.PrintingExtensionVersion is documented as being for display
and returned the raw value; it returns the printable form. A version that
passed validation is unchanged by that, which is the only case the realtime
upgrade reads it in.
Because the grant rules are shared with browser sign-in, the "grant
rejected" log line carries the same printable values.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The compatibility tests asserted that no credential is sent and that a
redirect is not followed, but neither assertion could fail: no client in the
file was signed in or bound, and the scripted handler cannot follow a
redirect whatever the client asks of it.
The tests that make the claim now run the check on a client that is signed
in with a device bound, on the success path and on every failure path, and
require that the check's requests carry no cookie, device proof or other
credential and that the session and binding are afterwards exactly as they
were (a device request still goes out with that cookie and that proof). The
redirect test uses a server that follows its own redirect whenever the
transport policy the client supplied allows it, and records whether the
target was asked.
Made able to fail, they found one thing wrong. A 401 answer to the check's
public discovery or sign-in description request went through the shared
authorization mapping and cleared the held session, so an operator looking
at a server could sign the service out. Those two requests now ask for the
session to be kept: the 401 is still reported as "asks for authorization",
and the session is untouched. DiscoverAsync and the sign-in paths are
unchanged.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The contract limits were tested on the refusing side only, and mostly far
past the limit, so a constant could move without a test noticing. Each
limit now has a row exactly at it, which is accepted, and a row one step
past it, which is refused: identifier length and character set, title,
format and scale tokens, hash length, content length, copies, paper size,
the display-only creation time and status, the queued list's length, the
reader depth for claim and queued responses, and for realtime frames the
depth, type, message identifier, acknowledged identifier, error code,
command identifiers, action and lifetime.
A theory passes every realtime-*.message.json fixture of the shared contract
through RealtimeEnvelope.Parse (four of them reached it before), and a test
pins that all nineteen are present.
The worker fake built every job with an uppercase hash; the contract says
lowercase, and the fake now follows it. The client keeps accepting capitals:
that is looser than the contract, the agent always did, and the hash is only
ever compared with the downloaded bytes' hash without regard to case. A test
states that on purpose, with a document that matches and one that does not.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Several edges of the FAILED / OUTCOME_UNKNOWN line were executed by tests
but never asserted. Each now has a test that states the outcome, and one
was wrong.
Wrong: a lease whose release threw after the job was recorded as SPOOLED
went through the failure decision and wrote OUTCOME_UNKNOWN on top of
SPOOLED. Windows had accepted the document; a failed release says nothing
about the print. The record now stays SPOOLED, with its spooler receipt,
and the failed release is logged.
Clearer, same outcome: the two things that can stop a job after SUBMITTING
is recorded and before Windows is asked now say that no bytes were
submitted, so an operator who reads SUBMITTING then FAILED is not left to
guess. Cancellation there was recorded as "The operation was canceled.",
and an undeliverable SUBMITTING receipt with the transport's own message.
Both remain FAILED: Windows was never asked.
Asserted, and right as they were: every failure on the staged-file path,
which is the path production runs (FAILED before Windows is asked or on a
media refusal, OUTCOME_UNKNOWN once the file was handed over, the staged
file gone and every lease released in each case, no second download or
print on a re-offer); a replayed event keeps its EventId; a delivery
failure in the middle of a batch does not resend the events before it.
Also removes a fourth blank line between two members of the worker.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The fake socket the realtime channel is tested against did not fault a
receive that was waiting when it was aborted or disposed, and let sends
succeed after it had closed. A channel bug that depends on what a socket
does when it ends could therefore pass every test.
It now does what the platform's client socket does: an abort ends a waiting
receive with a cancellation and leaves the socket aborted; a dispose ends it
as closed prematurely; after either, receives and sends report the socket
as disposed, frames queued earlier are not handed out, and a send after the
close was sent reports an invalid state. A send the test's hook is holding
fails if the socket ends meanwhile.
That is asserted, not assumed: a test ends the fake and a real
ClientWebSocket on a loopback peer in the same three ways and requires the
same observations from both.
PrintingRealtimeChannel is not changed here; how RunAsync ends is being
changed on another branch.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two gaps in the compatibility check.
A TLS failure while reading the sign-in description came back as
SecureConnectionFailed without the certificate the client had refused,
although the same failure at discovery carries it. The certificate is now
returned with the problem at whichever request it was refused, and never
beside a failure it did not cause.
When the caller cancelled just as another failure surfaced, the other
failure escaped: a connection error instead of the cancellation. The
caller's cancellation is now checked first and thrown, at either request,
whatever else went wrong at that moment.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The injected-clock tests advanced the clock to one millisecond before a
limit and then asserted that the operation had not completed. That passes
even if the limit fired early, because the completion had simply not been
observed yet. Each test now asserts that the limit's own timer is still
armed and due in exactly that millisecond, which it cannot be once it has
fired, and the test of a request bounded only by its caller asserts that no
timer is armed at all.
The comments that said every limit runs on the injected clock now name the
exception: HttpClient.Timeout on the API client, which the platform counts
on the system clock.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
PrintingProtocolException was documented as the one failure for everything
the server gets wrong. It is not: the HTTP control poll and receipt,
enrollment, the operator catalog, operator actions, realtime settings and
downloads report a bad response as InvalidDataException or JsonException,
and the command rule raises one type over the realtime channel and the
other in a polled batch. The documentation is corrected to say exactly what
the type covers; the failures themselves are left as they are, because
their callers in the Windows layer and the companion handle those types.
docs/REALTIME.md gains a section on the jobs the worker does not print: a
malformed job, a claimed job that is already submitting, the stretch
between SUBMITTING and the native call, a release that fails after spool
acceptance, and the file backend a staging worker must be built with.
The coverage exclusion on QqqPlatformSocketConnector said both that the
class cannot run without a live server and that a loopback test runs it end
to end. It now says what is true: the loopback test runs one accepted
upgrade, and the rest is platform behavior, not logic to cover.
Also restores the third blank line between two members of the channel.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two members of ClaimedJobContractTests were separated by two blank lines.
No test changes.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A claim nested beyond 64 levels was "not valid JSON" to the parser, so it
named no job, the lane stopped, and the server offered it again. Options
nested about 62 deep fit in roughly 320 characters, well inside the 2,000
the server allows a submitter, so one request could stall a device.
The claim and queued responses are now parsed to 512 levels (the body is
already limited to 2 MiB), and a claimed job may nest to 64 levels counted
from the response, as before. A job nested deeper than that is identified
and refused: RECEIVED, then FAILED with fixed text and nothing submitted,
and the lane goes on to the next job. Only a response nested past 512
levels still names no job and stops the lane; options that deep do not fit
in what the server accepts.
The queued list is only displayed, so an entry may nest as deep as the
response is parsed: a job that will be refused when claimed is still shown.
The nested-65 rows are replaced: depth 64 is accepted, 65, 66 and 512 are
refused with a stand-in and the next job is claimed, and 513 stops the lane.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two statements said more than the code can know.
The FAILED reason recorded when the flush after SUBMITTING fails said the
submitting receipt "could not be delivered". The flush sends every pending
event, so it may have failed on another job's event, on a lost
acknowledgement or on the journal. The reason now says that delivery of the
receipt could not be confirmed; it still says that no bytes were submitted,
which is the part that is certain.
The worker's comments and docs/REALTIME.md said a staging worker can never
have a job fail part-way for lack of file support. The construction check
proves only that the backend handed to the worker declares the file
interface. The Windows runtime's wrappers always declare it and forward to
the backend inside them, so a wrapped backend without file support passes
the check. Both now say exactly that.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
PrintingExtensionVersion returned the text "(missing)" when the single
printing extension stated no version, so a caller could not tell an absent
version from a value. It is null whenever there is nothing to show (no
printing extension, more than one, or one with no version or an empty one),
and the printable form of the value when there is one. The wording of an
absence stays where text is composed for display: the problem detail and the
compatibility log line.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The problem details were made printable earlier, but the session client's
own log lines still carried raw text from an unauthenticated server: the
discovery summary, the authentication type and provider name, the grant's
values, the session identity, the status text and content type of a
response, the identity provider's issuer and endpoints, a browser
callback's parameter names, error and description, the subprotocol the
server selected, the message of a parse or socket failure, and a refused
certificate's subject and issuer. A newline in any of them forged a log
line, and a long one flooded the log.
Every log line of the session client that carries such a value now passes
it through the same Printable the problem details use: 80 units for a
value, 512 for longer text (an address, a certificate name, an error
description, a failure's message), and a list is cut at 32 values with the
rest counted. The certificate names in the rejection handed to the wizard
are printable in the same way.
The tests put a hostile value (a line break, a terminal escape sequence,
forged log text, a line separator, a right-to-left override, padding up to
what fills a 64 KiB response) into each of those places with debug logging
on, require every logged line to be printable and bounded, and name the
lines that must still carry the value.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
AGENTS.md requires the threat model to follow a boundary change, and this
branch changed two without touching it.
docs/security/threat-model.md gains "Claim boundary": a job's options are
written by its submitter and relayed by the server unchanged; what the
client validates and bounds at the claim; that an identifiable bad job is
refused as FAILED with fixed text and nothing server-supplied in the
reason; the repeated-name rule for members the client reads and why; and
the residual case in which a claim cannot be identified and the lane stops
until the job is cancelled on the server. It also gains "Compatibility
check": no credential is sent, a held session is never ended, and server
text is shown and logged only in printable, bounded form.
CHANGELOG.md gains an Unreleased entry for the operator-visible differences
of the branch.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
PrintingRealtimeChannel.RunAsync could only end by throwing. The caller's
cancellation came back as an OperationCanceledException like any other;
disposing the channel while it ran also surfaced as a cancellation, although
the caller's token was never cancelled, or as whatever the reader met on the
disposed socket; and the method's closing line could not be reached.
How the run ends now says why the channel ended. It returns normally when
it was ended on purpose: the caller cancelled the token it passed in and
neither loop had failed, or the channel was disposed while it ran. It throws
when the channel ended by itself, exactly as before: the reader's failure
first, otherwise the writer's. A callback that gives up by throwing a
cancellation while the caller's token is not cancelled still ends the run
as that cancellation, which is what the service's callback does when a newer
connection has replaced this one.
This was chosen over returning an end reason because the service reads the
ending from the task: AgentRuntime.CompleteConnectionAsync takes the
reader's failure from the task's exception and logs the reason by exception
type. Failures therefore stay exceptions, unchanged, and nothing in the
Windows layer changes. On the caller's cancellation the run's task is now
completed instead of cancelled; the service's other workers are cancelled by
the same token, so what it awaits still ends as that cancellation.
What release/2.0 added is kept exactly: a send released by a closing channel
fails with RealtimeChannelClosedException carrying what ended the channel.
A send released by Dispose gets the same type, with no cause.
Tests cover each ending: caller cancellation, server close, reader failure,
writer failure, dispose while running (the two endings on purpose also on a
real ClientWebSocket), a callback that gives up, and a failure that arrives
together with a cancellation.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A send can pass the closed check, have the channel close while its frame is
serialized, and then find the outgoing queue completed. It was told "Realtime
output queue is full", which the service reports as the reason a connection
dropped, in place of what really ended the channel. In that branch the send
now gets RealtimeChannelClosedException when the channel is closed, and the
socket is not aborted on its account; a queue that really is full is still
reported as full and still aborts the socket. Both are tested.
For the same reason PrintApiClient's "no channel to send on" failure uses
the closed type too. Nothing depends on the plain IOException there: the
service supplies a provider that already throws the closed type.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Core measured 100% of 2,245 lines in three consecutive verification runs.
An assembly that has reached its release bar is recorded at the bar.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
Parsing to 512 levels did not close the stall. The estimate behind it
counted five characters a level, but an array inside an array costs two, and
the server's JSON library sets no depth limit: options with 510 nested
arrays are 1,059 characters, well inside the server's 2,000-character cap,
and make the claim 513 levels deep, which named no job and stopped the lane.
The parse depth is now derived from that cap: about 1,000 levels fit in
2,000 characters at two a level, and the response, the job and the options
add three, so claim and queued responses are parsed to 1,024 levels. A job
may still nest to 64 levels counted from the response; deeper is identified
and refused. The work stays bounded: the document parse and the
serializer's skipping of unknown fields are loops, and the one recursive
walk stops after the 63 levels it is asked about.
The nested-513 rows are replaced by rows at 1,024 (identified and refused)
and 1,025 (not valid JSON, the lane stops), by objects and by arrays, and a
theory builds options from nested arrays the way the server was probed: 510
arrays, the 970 that just fit the cap, and 1,021, each identified and
refused. docs/REALTIME.md says the residual case is now a response nested
past 1,024 and that its closure rests on the server's cap.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Three statements in the threat model said more than the code does.
The residual case named 512 levels; it is a response nested past 1,024,
which cannot be produced through the server's 2,000-character options cap
as it stands, and the text now says the closure rests on that cap.
A refusal was said to carry no text into the journal, the server's history
or the companion. That is true of its reason, which is fixed text; the
refused job's title is kept in the stand-in, stored and shown like any
job's, and the text now says so.
Identifiers were called safe in a file name. They allow a colon, which is
not safe in a Windows file name, and nothing uses them as one.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The 80-unit cut could hide exactly what an operator compares. A principal
or tenant may run to 200 characters under the contract, so two that share a
long beginning printed identically in "was bound to ... now reports ...".
Each callback address the server lists was cut at 80 in the very line that
tells the operator which callback to register.
Identity values (principal, tenant, device) and the addresses in the listed
callbacks are now shown up to the 512-unit limit already used for other
addresses. They are still printable and still bounded; the list is still
cut at 32 values. The redirect list inside a problem detail keeps the short
cut, because the whole detail is limited to 512.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The channel's run now returns when its caller cancels it, so in an orderly
teardown the reader task completes where it used to be cancelled. The
CompleteConnectionAsync tests still modelled the reader as cancelled in
every case where nothing failed. Two cases are added with the reader
completed: the workers cancelled, which must still report cancellation, and
a worker failed, whose failure must still be the reason.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Hardens
Qqq.PrintAgent.Coreat its boundaries, makes it fully testable, and brings it to 100% line coverage (2,246 of 2,246 lines; 793 tests, up from 200), gated at 100.What the server sends is validated where it arrives
PrintJobContract): identifiers, title, format, hash, length, copies, scale, paper and media. A job that breaks the contract but can be identified is refused (RECEIVED, then FAILED with fixed text, "no bytes were submitted") and the lane goes on to the next job. Before, such a job either threw part-way or stopped the device's whole queue until someone cancelled it on the server.RealtimeEnvelope; ACK, ERROR and COMMAND payloads are validated; a violation is one exception type (PrintingProtocolException).The print boundary is tested at its edges and one outcome is corrected
A typed compatibility check for the wizard
QqqSessionClient.CheckCompatibilityAsyncreturns stages (connection, security, server, printing, sign-in), the problems found, the sign-in methods offered and, for a refused certificate, what was presented. It sends no credential and never ends a held session (a 401 to its public requests used to clear the session).Seams
TimeProvider(exceptHttpClient.Timeout); HTTP and WebSocket transports sit behindIQqqTransportandIQqqSocketConnector; four platform adapters are excluded from coverage with a written reason.PrintingRealtimeChannel.RunAsyncreturns when it was ended on purpose and throws when it ended by itself; a send overtaken by the close is told the channel closed.Docs:
docs/REALTIME.md(jobs the worker does not print),docs/security/threat-model.md(claim boundary, compatibility check),CHANGELOG.md.Verification, local, at this head on
f78943e:build/Verify.ps1passed twice with no failing test: 793 Core and 604 Windows/CLI tests, zero warnings; Core 100%, command line 100%, Windows 70.15%. The same branch passed three times before the last four commits.Not verified: no real server (in particular that it accepts RECEIVED then FAILED for a refused job and stops offering it), no installed service, no physical printing.
Review: independent, four passes. Pass one found a blocker (a contract-valid job that stalled the queue) and nine should-fix items; pass two found two; pass three found one (the parse depth did not cover array nesting); each was fixed as proposed. The reviewer confirmed the replay onto the release branch as faithful and the channel change as invisible to the service.
Known, recorded for the Windows layer: a replay of a job another lane holds is stopped only by an accidental check; a printer unshared while jobs queue marks them OUTCOME_UNKNOWN although Windows was never asked; the journal cuts a long title by UTF-16 units.
🤖 Generated with Claude Code
Seven session operations logged their failure from an exception filter that always returned false, leaving a `{ throw; }` body no test could ever reach. They now catch, log and rethrow, which is the same behaviour with nothing unreachable. HttpClientHandler, SocketsHttpHandler and ClientWebSocket were constructed inline in QqqSessionClient. Construction moves behind IQqqTransport and IQqqSocketConnector; the production implementations (QqqPlatformTransport, QqqPlatformSocketConnector) only build platform objects from what the client hands them and are excluded from coverage with a written reason. The redirect and cookie policy, both certificate decisions, the upgrade request and the meaning of every upgrade outcome stay in QqqSessionClient and are now tested through a fake transport. Also: the realtime discovery refresh no longer dereferences an empty slot in the server's extension list, the browser callback checks move to their own method, and TrafficHttpContent drops an override the platform never calls. Signed-off-by: James Maes <james@kof22.com> Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>