Merge pull request 'feat(realtime): printer-approval-v1 and the PRINTERS frame for extension 2.4.0' (#11) from feat/printer-approval-20261105 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Failing after 2s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI / verify (push) Failing after 1s
QRun-IO/ci-policy: Shared Node CI / secrets (push) Failing after 1s
QRun-IO/ci-policy: Shared Node CI / gate (push) Failing after 1s
QRun-IO/ci-policy: ci / node-8 (push) Failing after 1m26s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 1s

This commit was merged in pull request #11.
This commit is contained in:
james.maes committed 2026-11-05 22:22:14 +00:00
commit 5005f3c9e2
13 files changed
+223 -14

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# Changelog
## 1.1.0 — unreleased
Additive contract for printing semantic extension **2.4.0**. Wire payload stays
**v1** and the subprotocol stays `qqq-printing.v1`; every 2.3.0 fixture is unchanged.
- Add the `printer-approval-v1` capability to `RealtimeHelloPayload` (`maxItems` 5)
and `ControlCapability`; the historical control poll and management
`Device.control` accept four non-realtime capabilities.
- Add the server → client `PRINTERS` frame (`RealtimePrinters`,
`RealtimePrintersPayload`, `PrinterApprovalState`) carrying the complete approval
state of the device's latest accepted inventory; sent only to connections that
advertised `printer-approval-v1`, after each accepted INVENTORY and on every
approval change. Not acknowledged, not durable, no job or document data.
- Add `realtime-printers.message.json`, a five-capability hello and a 2.4.0
discovery fixture; the verifiers cover the directional frame, bounds and
required fields. See [REALTIME.md](docs/REALTIME.md#printer-approval) and the
[version registry](docs/VERSIONS.md).
## 1.0.0
Distribution version 1.0.0 of the contract and fixtures from 0.4.0-rc.1. The wire
+7
View File
@@ -13,6 +13,13 @@ granting device management. Use the stable cohort above for these profiles;
older 0.3.2 clients do not support them. Wire version 1 and the client-initiated
realtime transport stay the same.
Source version **1.1.0** (unreleased) adds printing semantic extension **2.4.0**:
the `printer-approval-v1` HELLO capability and the server → client `PRINTERS`
frame, which reports each exposed printer's server approval over the
[realtime channel](docs/REALTIME.md#printer-approval). Clients advertise it only
after discovering a stable 2.4+ extension; a 2.3.0 server or a client without the
capability keeps the 2.3.0 wire unchanged.
## Current connection and delivery
1. Configure the QQQ server origin in the Windows app. Discover its UI/UX API and sign in using the server's configured authentication method.
+24 -1
View File
@@ -32,7 +32,9 @@ The client opens one persistent WebSocket to the configured QQQ origin:
`X-QQQ-Device-Id`, `X-QQQ-Device-Token`, `X-QQQ-Client: api/qqq-print-agent`
and the canonical same-origin `Origin`.
- Capability: `realtime-v1` is mandatory in `HELLO`. Optional command capabilities
are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`.
are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`; optional feature
capabilities are `printer-media-v1` (extension 2.3.0) and `printer-approval-v1`
(extension 2.4.0), each gated by [discovery](VERSIONS.md#negotiation-rules).
Do not add credentials to URLs, subprotocols, logs or frame payloads. Refuse
cross-origin redirects and arbitrary content URLs. The existing session is
@@ -68,6 +70,7 @@ compression that could bypass decoded-size limits are not supported.
| Server → client | DESIRED_STATE | Existing `DeviceDesiredState` directly |
| Server → client | SETTINGS | `RealtimeSettings` directly |
| Server → client | JOB_AVAILABLE | Empty object; a coalescible wake-up, not a job or claim |
| Server → client | PRINTERS | `printers`: complete `printerId`/`enabled` approval of the latest accepted inventory; only after `printer-approval-v1` |
| Server → client | COMMAND | Existing `DeviceCommand` directly |
| Client → server | JOB_EVENT | `jobId`, existing `JobEvent` as `event` |
| Client → server | COMMAND_RECEIPT | `commandId`, existing `DeviceCommandReceipt` as `receipt` |
@@ -97,6 +100,26 @@ refreshing inventory or querying jobs/controls. Only one serialized writer owns
socket sends; the receive/control path must not await document download,
rendering, printer submission or UI work.
### Printer approval
Extension 2.4.0 adds `printer-approval-v1`. A client advertises it only after
discovering a stable 2.4+ extension within major two. The server then sends
PRINTERS (`RealtimePrinters`) on that connection, through the same serialized
writer, after it has processed an accepted INVENTORY from that connection
(after that INVENTORY's ACK) and whenever the approval of any printer belonging
to the device changes by any server-side path. Nothing else triggers it; there
is no timer and the client does not acknowledge it.
The payload lists every printer in the device's latest accepted inventory, for
this device only, as `printerId` plus boolean `enabled`, at most 200 entries.
`enabled:false` means the server knows the printer and has not approved it. A
printer the device no longer exposes is not listed; an empty inventory yields
`{ "printers": [] }`. Each frame replaces the previous one entirely, so frames
are idempotent and coalescible like JOB_AVAILABLE. The frame is not a durable
event and carries no job or document data. A client that did not advertise the
capability never receives it and keeps reading its existing authorized catalog
for approval; a server never sends it to a connection that did not advertise it.
## Ordering, synchronization and replay
1. Authenticate the connection, then require HELLO before other application work.
+13 -4
View File
@@ -5,7 +5,7 @@ compare them as if they were one version sequence. See the [stable cohort](INTEG
| Number | Meaning | Current |
| --- | --- | --- |
| Protocol distribution | Version of the specification and fixture archive | 1.0.0 |
| Protocol distribution | Version of the specification and fixture archive | 1.1.0 (unreleased; 1.0.0 published) |
| Wire payload version | Shape of the JSON messages in this repository | 1 |
| WebSocket subprotocol | Framing and message envelope | `qqq-printing.v1` |
| Printing semantic extension | Server feature level discovered at `/qqq/v1/version` | see below |
@@ -21,22 +21,31 @@ additive and keeps the earlier behavior.
| 2.1.0 | Record, table-list and text print processes | server documentation |
| 2.2.0 | Render-to-PDF process, header and footer values, content sections. No media negotiation | server documentation |
| 2.3.0 | Least-privilege operator catalog, submission and request lookup; reported queue-default media and `printer-media-v1`; opt-in retained-source facade | [PRINTER-MEDIA.md](PRINTER-MEDIA.md) |
| 2.4.0 | `printer-approval-v1` and the server → client `PRINTERS` frame reporting each exposed printer's approval | [REALTIME.md](REALTIME.md#printer-approval) |
## Negotiation rules
- A client advertises a capability only when it supports it **and** the discovered
extension is a stable `major.minor.patch` version in major 2 at or above the
feature minimum. `printer-media-v1` has minimum 2.3.0.
feature minimum. `printer-media-v1` has minimum 2.3.0; `printer-approval-v1`
has minimum 2.4.0.
- A client facing 2.0, 2.1 or 2.2 stays a three-capability peer (`realtime-v1`,
`REFRESH_INVENTORY`, `CANCEL_SPOOL`) and sends no media inventory.
`REFRESH_INVENTORY`, `CANCEL_SPOOL`) and sends no media inventory. A client
facing 2.3 is at most a four-capability peer and receives no `PRINTERS` frame.
- A server sends `PRINTERS` only on a connection whose HELLO advertised
`printer-approval-v1`, whatever extension version it serves; a client that did
not advertise it keeps its own approval fallback.
- A server must not let a client that did not advertise `printer-media-v1` claim a
job that carries a media selection, whatever extension version that client
discovered.
- Capabilities are re-evaluated on each new socket, so a server upgrade from 2.0
to 2.3, or a downgrade from 2.3 to 2.2, takes effect at the next reconnect
to 2.4, or a downgrade from 2.4 to 2.2, takes effect at the next reconnect
without a polling schedule.
- Pre-release, ambiguous or malformed versions (`2.3.0-rc.1`, `2.3`, `02.3.0`,
four-part values) never enable a feature.
QBit 1.0.0 advertises native printing extension 2.3.0. Windows 1.0.1 is the paired
stable client; their product versions do not replace capability discovery.
QBit 1.1.0 advertises 2.4.0 and pairs with Windows 1.2.0; a 2.4.0 server still
serves 1.0.1/1.1.x clients as four-capability peers, and a 1.2.0 client facing
2.3.0 does not advertise `printer-approval-v1`.
@@ -0,0 +1,5 @@
{
"name": "printing",
"version": "2.4.0",
"pathPrefix": "/qbit/printing"
}
@@ -0,0 +1,27 @@
{
"type": "HELLO",
"messageId": "hello-connection-1",
"payload": {
"agentVersion": "1.2.0",
"capabilities": [
"realtime-v1",
"REFRESH_INVENTORY",
"CANCEL_SPOOL",
"printer-media-v1",
"printer-approval-v1"
],
"lastAppliedRevision": 0,
"lastSettingsRevision": 0,
"limits": {
"maxPdfBytes": 536870912,
"maxZplBytes": 104857600,
"maxFrameBytes": 16777216,
"maxQueuedFrames": 4096,
"maxQueuedFrameBytes": 67108864,
"maxConcurrentDownloads": 16,
"maxConcurrentPrints": 16,
"maxStagedBytes": 4294967296,
"maxCopies": 32767
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"type": "PRINTERS",
"messageId": "printers-connection-1",
"payload": {
"printers": [
{
"printerId": "laser-example",
"enabled": true
},
{
"printerId": "label-example",
"enabled": false
}
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@qrun-io/qqq-print-protocol",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qqq-print-protocol",
"version": "1.0.0",
"version": "1.1.0",
"license": "Apache-2.0",
"devDependencies": {
"@eslint/js": "10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-print-protocol",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "Versioned wire contract and compatibility fixtures for QQQ cloud printing.",
"license": "Apache-2.0",
+65 -5
View File
@@ -993,7 +993,8 @@
"enum": [
"REFRESH_INVENTORY",
"CANCEL_SPOOL",
"printer-media-v1"
"printer-media-v1",
"printer-approval-v1"
]
},
"ProtocolVersionResponse": {
@@ -1060,7 +1061,7 @@
"items": {
"$ref": "#/$defs/ControlCapability"
},
"maxItems": 3,
"maxItems": 4,
"uniqueItems": true
},
"lastPollAt": {
@@ -1099,7 +1100,7 @@
"items": {
"$ref": "#/$defs/ControlCapability"
},
"maxItems": 3,
"maxItems": 4,
"uniqueItems": true
},
"lastAppliedRevision": {
@@ -1718,11 +1719,12 @@
"realtime-v1",
"REFRESH_INVENTORY",
"CANCEL_SPOOL",
"printer-media-v1"
"printer-media-v1",
"printer-approval-v1"
]
},
"minItems": 1,
"maxItems": 4,
"maxItems": 5,
"uniqueItems": true,
"contains": {
"const": "realtime-v1"
@@ -2068,6 +2070,61 @@
},
"additionalProperties": false
},
"PrinterApprovalState": {
"description": "Server approval of one printer in the device's latest accepted inventory; enabled:false means known but not approved.",
"type": "object",
"required": [
"printerId",
"enabled"
],
"properties": {
"printerId": {
"$ref": "#/$defs/Id"
},
"enabled": {
"type": "boolean"
}
},
"additionalProperties": true
},
"RealtimePrintersPayload": {
"description": "Complete approval state of every printer the device currently exposes; each frame replaces the previous one.",
"type": "object",
"required": [
"printers"
],
"properties": {
"printers": {
"type": "array",
"items": {
"$ref": "#/$defs/PrinterApprovalState"
},
"maxItems": 200
}
},
"additionalProperties": true
},
"RealtimePrinters": {
"description": "Sent only on connections whose HELLO advertised printer-approval-v1; not acknowledged.",
"type": "object",
"required": [
"type",
"messageId",
"payload"
],
"properties": {
"type": {
"const": "PRINTERS"
},
"messageId": {
"$ref": "#/$defs/Id"
},
"payload": {
"$ref": "#/$defs/RealtimePrintersPayload"
}
},
"additionalProperties": false
},
"RealtimeCommand": {
"type": "object",
"required": [
@@ -2184,6 +2241,9 @@
{
"$ref": "#/$defs/RealtimeJobAvailable"
},
{
"$ref": "#/$defs/RealtimePrinters"
},
{
"$ref": "#/$defs/RealtimeCommand"
},
+3
View File
@@ -4,6 +4,7 @@
// Shared correspondence between wire examples and schema definitions.
export const fixtureTypes = {
"printing-realtime-extension.discovery.json": "PrintingRealtimeExtensionDiscovery",
"printing-realtime-extension-240.discovery.json": "PrintingRealtimeExtensionDiscovery",
"version-legacy.response.json": "ProtocolVersionResponse",
"version-controls.response.json": "ProtocolVersionResponse",
"control-poll.request.json": "DeviceControlPollRequest",
@@ -82,4 +83,6 @@ export const fixtureTypes = {
"operator-printers.response.json": "OperatorPrinterList",
"operator-submit.request.json": "OperatorSubmitJobRequest",
"realtime-media-hello.message.json": "RealtimeHello",
"realtime-approval-hello.message.json": "RealtimeHello",
"realtime-printers.message.json": "RealtimePrinters",
};
+40 -1
View File
@@ -27,6 +27,10 @@ for(const version of ["1.0.0", "1.1.0", "3.0.0", "2.0", "2.01.0", "2.0.0-rc.1",
}
assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, name: "another-extension" }));
assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, pathPrefix: "/printing/v1" }));
const approvalExtension = fixture("printing-realtime-extension-240.discovery.json");
assert(valid("PrintingRealtimeExtensionDiscovery", approvalExtension), ajv.errorsText());
assert.equal(approvalExtension["version"], "2.4.0", "printer-approval-v1 has feature minimum 2.4.0.");
assert.deepEqual({ ...approvalExtension, version: extension["version"] }, extension, "2.4.0 keeps the 2.x descriptor name and path prefix.");
/*******************************************************************************
** Cross-field policy relationships require semantic checks beyond JSON Schema.
@@ -91,8 +95,23 @@ assert(!valid("RealtimeHello", { ...hello, messageId: "hello:unsafe-prefix" }));
assert(!valid("RealtimeHello", { ...hello, token: "not-a-real-token" }));
assert(!valid("RealtimeClientMessage", { type: "RUN_SHELL", messageId: "unsupported-1", payload: {} }));
/*******************************************************************************
** Capability hellos: the 2.3.0 four-capability and 2.4.0 five-capability peers
** both remain valid; the list is bounded, unique and closed.
*******************************************************************************/
const mediaHello = /** @type {typeof hello} */ (/** @type {unknown} */ (fixture("realtime-media-hello.message.json")));
const approvalHello = /** @type {typeof import("../fixtures/realtime-approval-hello.message.json")} */ (/** @type {unknown} */ (fixture("realtime-approval-hello.message.json")));
assert(valid("RealtimeHello", mediaHello), ajv.errorsText());
assert(valid("RealtimeHello", approvalHello), ajv.errorsText());
assert.equal(approvalHello.payload.capabilities.length, 5);
assert(approvalHello.payload.capabilities.includes("printer-approval-v1"));
assert.deepEqual(approvalHello.payload.limits, hello.payload.limits, "Approval negotiation does not change declared ceilings.");
assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v1"] } }), "Duplicate capability accepted");
assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v2"] } }), "Unknown sixth capability accepted");
assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: ["printer-approval-v1"] } }), "realtime-v1 remains mandatory");
const clientFixtures = ["hello", "state-applied", "job-event", "command-receipt", "heartbeat", "inventory", "ping"];
const serverFixtures = ["welcome", "desired-state", "job-available", "command", "settings", "ack", "error", "pong"];
const serverFixtures = ["welcome", "desired-state", "job-available", "printers", "command", "settings", "ack", "error", "pong"];
for(const [files, accepted, rejected] of /** @type {[string[],string,string][]} */ ([
[clientFixtures, "RealtimeClientMessage", "RealtimeServerMessage"],
[serverFixtures, "RealtimeServerMessage", "RealtimeClientMessage"],
@@ -110,6 +129,26 @@ for(const [files, accepted, rejected] of /** @type {[string[],string,string][]}
assert(!valid(accepted, { ...message, contentUrl: "https://unrelated.invalid/document" }));
}
}
/*******************************************************************************
** PRINTERS carries the complete approval state of the latest accepted inventory;
** entries are bounded, both fields are required and the envelope stays closed.
*******************************************************************************/
const printers = /** @type {typeof import("../fixtures/realtime-printers.message.json")} */ (/** @type {unknown} */ (fixture("realtime-printers.message.json")));
assert.equal(printers.payload.printers.length, 2);
assert.deepEqual(printers.payload.printers.map(printer => printer.enabled), [true, false], "Fixture shows one approved and one awaiting printer.");
assert(valid("RealtimePrinters", { ...printers, payload: { printers: [] } }), "An empty inventory yields an empty approval list.");
assert(valid("RealtimePrinters", { ...printers, payload: { printers: Array(200).fill(printers.payload.printers[0]) } }));
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: Array(201).fill(printers.payload.printers[0]) } }));
assert(valid("RealtimePrinters", { ...printers, payload: { ...printers.payload, futureField: true } }), "Additive payload fields remain compatible.");
assert(valid("RealtimePrinters", { ...printers, payload: { printers: [{ ...printers.payload.printers[0], futureField: true }] } }));
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example" }] } }), "enabled is required");
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ enabled: true }] } }), "printerId is required");
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example", enabled: "true" }] } }), "enabled must be boolean");
assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "", enabled: true }] } }));
assert(!valid("RealtimePrinters", { ...printers, payload: {} }), "printers list is required");
assert(!valid("RealtimePrinters", { ...printers, type: "JOB_AVAILABLE" }));
assert(!valid("RealtimeJobAvailable", { ...printers, type: "JOB_AVAILABLE" }), "PRINTERS payload is not the empty wake-up");
const event = fixture("realtime-job-event.message.json");
assert.deepEqual(fixture("realtime-job-event-replay.message.json"), event, "Lost ACK replay preserves the exact envelope.");
assert.equal((/** @type {typeof import("../fixtures/realtime-ack.message.json")} */ (/** @type {unknown} */ (fixture("realtime-ack.message.json")))).payload.acknowledgedMessageId, event["messageId"]);
+2
View File
@@ -77,6 +77,8 @@ const poll = control("control-poll.request.json");
assert(!validControl("DeviceControlPollRequest", { ...poll, controlVersion: 2 }));
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] }));
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["RESTART_SERVICE"] }));
assert(validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"] }), "Historical control poll accepts every non-realtime capability.");
assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1", "printer-approval-v1"] }));
assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: -1 }));
assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: 9007199254740992 }));
const pollResult = /** @type {typeof import("../fixtures/control-poll.response.json")} */ (/** @type {unknown} */ (control("control-poll.response.json")));