diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 6e84751..423c3a1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
version: 2
updates:
- package-ecosystem: npm
diff --git a/.github/scripts/check-codeql-sarif.py b/.github/scripts/check-codeql-sarif.py
index cf44763..8705490 100644
--- a/.github/scripts/check-codeql-sarif.py
+++ b/.github/scripts/check-codeql-sarif.py
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
import json
import math
diff --git a/.github/scripts/check-source-headers.py b/.github/scripts/check-source-headers.py
new file mode 100644
index 0000000..2392c7e
--- /dev/null
+++ b/.github/scripts/check-source-headers.py
@@ -0,0 +1,128 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
+"""Check leading notices in explicitly owned, Git-tracked source families."""
+
+import fnmatch
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+
+def selected(name: str, policy: dict[str, Any]) -> bool:
+ """Select owned paths, honoring documented exact/pattern exclusions first."""
+ if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]):
+ return False
+ return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
+
+
+def leading_comments(text: str, style: str) -> str:
+ """Read comments only, preserving required interpreter/XML directive ordering."""
+ text = text.removeprefix("\ufeff")
+ if text.startswith("", text)
+ if style != "xml" or declaration is None:
+ raise ValueError("Invalid XML declaration")
+ text = text[declaration.end():]
+ elif text.startswith("#!"):
+ if style not in ("hash", "powershell", "slash"):
+ raise ValueError("Unexpected interpreter directive")
+ text = text.partition("\n")[2]
+ while re.match(r"#Requires\b", text, re.IGNORECASE):
+ if style != "powershell":
+ raise ValueError("Unexpected PowerShell directive")
+ text = text.partition("\n")[2]
+ comments = []
+ while text.strip():
+ text = text.lstrip()
+ if text.startswith(""), "powershell": ("<#", "#>")}[style]
+ body, separator, rest = text[len(opening):].partition(closing)
+ if not separator:
+ raise ValueError("Unterminated leading comment")
+ comments.append(body)
+ text = rest
+ else:
+ break
+ return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines())
+
+
+def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
+ """Require an approved holder/license pair in real leading comment syntax."""
+ suffix = Path(name).suffix
+ style = policy["styles"].get(suffix)
+ if Path(name).name.startswith("Dockerfile."):
+ style = "hash"
+ if style is None:
+ raise ValueError("Selected path has no configured comment syntax")
+ comments = leading_comments(text, style)
+ approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
+ for notice in approved:
+ holder = re.escape(notice["holder"])
+ copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
+ if re.search(copyright_pattern, comments) is None:
+ continue
+ spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
+ if spdx and any(value != notice["license"] for value in spdx):
+ continue
+ apache = all(phrase in comments for phrase in (
+ "Licensed under the Apache License, Version 2.0",
+ "you may not use this file except in compliance with the License.",
+ "https://www.apache.org/licenses/LICENSE-2.0",
+ "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
+ "limitations under the License.",
+ ))
+ if suffix == ".java" and not apache:
+ continue
+ if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
+ return
+ raise ValueError("Missing or mismatched leading copyright/license notice")
+
+
+def check_repository(root: Path, policy: dict[str, Any]) -> int:
+ """Check tracked sources without scanning build outputs or executing source."""
+ tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
+ failures = []
+ count = 0
+ for name in tracked:
+ if not name or not selected(name, policy):
+ continue
+ count += 1
+ try:
+ check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy)
+ except (ValueError, OSError) as error:
+ failures.append(f"{name}: {error}")
+ if count == 0:
+ raise ValueError("No owned source files selected")
+ if failures:
+ raise ValueError("\n".join(failures))
+ return count
+
+
+def main() -> None:
+ """Load the repository-specific reviewed scope and fail CI on a bad notice."""
+ root = Path(__file__).resolve().parents[2]
+ policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8"))
+ count = check_repository(root, policy)
+ print(f"Leading source notices verified: {count} tracked files")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (ValueError, OSError, subprocess.CalledProcessError) as error:
+ print(str(error), file=sys.stderr)
+ sys.exit(1)
diff --git a/.github/scripts/test-scan-gates.py b/.github/scripts/test-scan-gates.py
index d9356b2..c8494eb 100644
--- a/.github/scripts/test-scan-gates.py
+++ b/.github/scripts/test-scan-gates.py
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
"""Exercise the exact CI guards using synthetic evidence only."""
import contextlib
import copy
diff --git a/.github/scripts/test-source-headers.py b/.github/scripts/test-source-headers.py
new file mode 100644
index 0000000..8a04b44
--- /dev/null
+++ b/.github/scripts/test-source-headers.py
@@ -0,0 +1,156 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
+"""Exercise notice parsing, provenance, scope and real Git inventory rejection."""
+
+import importlib.util
+import json
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from typing import Any
+
+
+SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
+assert SPEC is not None and SPEC.loader is not None
+HEADERS = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(HEADERS)
+POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8"))
+MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT"
+APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+https://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License."""
+
+
+def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
+ """Use the production styles with a small explicit owned fixture scope."""
+ return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
+ "notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
+
+
+class HeaderTests(unittest.TestCase):
+ """Ensure policy failures cannot pass through text-only copyright searches."""
+
+ def test_each_real_comment_syntax(self) -> None:
+ """Recognize hash, slash, block and XML notices in their own languages."""
+ for name, text in (
+ ("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"),
+ ("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"),
+ ("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"),
+ ("src/a.xml", "\n"),
+ ("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"),
+ ):
+ with self.subTest(name=name):
+ HEADERS.check_notice(name, text, fixture_policy())
+
+ def test_required_first_lines_and_bom(self) -> None:
+ """Accept shebangs, PowerShell requirements and XML declarations first."""
+ for name, prefix, body in (
+ ("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
+ ("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
+ ("src/a.xml", "\n", ""),
+ ("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
+ ):
+ with self.subTest(name=name):
+ HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy())
+
+ def test_misordered_required_first_lines_fail(self) -> None:
+ """A notice must not displace an interpreter or XML declaration."""
+ for name, text in (
+ ("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
+ ("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
+ ("src/a.xml", ""),
+ ):
+ with self.subTest(name=name), self.assertRaises(ValueError):
+ HEADERS.check_notice(name, text, fixture_policy())
+
+ def test_code_strings_and_late_comments_do_not_count(self) -> None:
+ """Reject docstrings and comments after executable declarations."""
+ for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
+ "\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
+ with self.subTest(text=text), self.assertRaises(ValueError):
+ HEADERS.check_notice("src/a.py", text, fixture_policy())
+
+ def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
+ """Notice text alone is insufficient without a complete language comment."""
+ for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
+ with self.subTest(text=text), self.assertRaises(ValueError):
+ HEADERS.check_notice("src/a.cs", text, fixture_policy())
+ with self.assertRaises(ValueError):
+ HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy())
+
+ def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
+ """Do not conflate repository licenses or accept conflicting identifiers."""
+ for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
+ MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
+ with self.subTest(text=text), self.assertRaises(ValueError):
+ HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
+
+ def test_complete_legacy_apache_without_spdx(self) -> None:
+ """Retain valid legacy year ranges and complete Apache notices."""
+ policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
+ HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy)
+ HEADERS.check_notice("src/a.xml", "", policy)
+
+ def test_java_requires_full_apache_block(self) -> None:
+ """An abbreviated SPDX-only Java notice cannot replace scoped convention."""
+ policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
+ for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
+ APACHE.replace("limitations under the License.", "")):
+ with self.subTest(notice=notice), self.assertRaises(ValueError):
+ HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
+
+ def test_virtual_agent_provenance_is_path_scoped(self) -> None:
+ """Only the owned virtual-agent family may use its existing QRun.IO holder."""
+ policy = fixture_policy()
+ policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]})
+ text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/"
+ HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy)
+ with self.assertRaises(ValueError):
+ HEADERS.check_notice("src/a.mjs", text, policy)
+
+ def test_current_scope_and_documented_exclusions(self) -> None:
+ """New scripts enter the guard; data, generated and upstream files do not."""
+ self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
+ self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
+ for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
+ "src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
+ "src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
+ with self.subTest(name=name):
+ self.assertFalse(HEADERS.selected(name, POLICY))
+ self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
+
+ def test_guard_sources_match_this_repository_provenance(self) -> None:
+ """Check copied guard files against local policy even before Git staging."""
+ for filename in ("check-source-headers.py", "test-source-headers.py"):
+ path = Path(__file__).with_name(filename)
+ name = ".github/scripts/" + filename
+ self.assertTrue(HEADERS.selected(name, POLICY))
+ HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY)
+
+ def test_real_git_inventory_rejects_missing_tracked_header(self) -> None:
+ """A tracked new source fails while untracked generated output is ignored."""
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
+ root.joinpath("src").mkdir()
+ source = root / "src/a.py"
+ source.write_text("print(1)", encoding="utf-8")
+ subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
+ with self.assertRaisesRegex(ValueError, "src/a.py"):
+ HEADERS.check_repository(root, fixture_policy())
+ source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
+ root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8")
+ self.assertEqual(1, HEADERS.check_repository(root, fixture_policy()))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/source-headers.json b/.github/source-headers.json
new file mode 100644
index 0000000..b9eefb5
--- /dev/null
+++ b/.github/source-headers.json
@@ -0,0 +1,98 @@
+{
+ "description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.",
+ "includes": [
+ ".github/*.yml",
+ ".github/*.yaml",
+ ".github/scripts/*.py",
+ "tools/*.mjs"
+ ],
+ "exclusions": [
+ {
+ "pattern": "*.json",
+ "reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments."
+ },
+ {
+ "pattern": "*.lock",
+ "reason": "Dependency lock data, not owned executable source."
+ },
+ {
+ "pattern": "*/generated/*",
+ "reason": "Explicit generated-code directory."
+ },
+ {
+ "pattern": "*/bin/*",
+ "reason": "Generated build output."
+ },
+ {
+ "pattern": "*/obj/*",
+ "reason": "Generated build output."
+ },
+ {
+ "pattern": "*/node_modules/*",
+ "reason": "Third-party dependency output."
+ },
+ {
+ "pattern": ".mvn/*",
+ "reason": "Upstream Maven wrapper provenance."
+ },
+ {
+ "pattern": "mvnw*",
+ "reason": "Upstream Maven wrapper provenance."
+ },
+ {
+ "pattern": "Assets/Legal/*",
+ "reason": "Byte-exact third-party legal files."
+ },
+ {
+ "pattern": "src/main/resources/*",
+ "reason": "Bundled font/legal assets and OpenAPI schema data."
+ },
+ {
+ "pattern": "src/test/resources/openapi/*",
+ "reason": "Historical contract schema fixtures."
+ },
+ {
+ "pattern": "*.slnx",
+ "reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies."
+ },
+ {
+ "pattern": "*.manifest",
+ "reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies."
+ },
+ {
+ "pattern": "*.runsettings",
+ "reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies."
+ },
+ {
+ "pattern": ".editorconfig",
+ "reason": "Style configuration outside this pass; root LICENSE/NOTICE applies."
+ }
+ ],
+ "styles": {
+ ".py": "hash",
+ ".ps1": "powershell",
+ ".psd1": "powershell",
+ ".sh": "hash",
+ ".yml": "hash",
+ ".yaml": "hash",
+ ".cs": "slash",
+ ".java": "slash",
+ ".mjs": "slash",
+ ".js": "slash",
+ ".xml": "xml",
+ ".props": "xml",
+ ".csproj": "xml",
+ ".wxs": "xml",
+ ".wixproj": "xml",
+ ".xaml": "xml"
+ },
+ "notices": [
+ {
+ "paths": [
+ "*"
+ ],
+ "license": "Apache-2.0",
+ "holder": "Kingsrook, LLC"
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 400870e..3d3bb4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
name: Protocol CI and release
on:
@@ -23,6 +26,10 @@ jobs:
node-version: '24'
package-manager-cache: false
- run: npm ci --ignore-scripts
+ - name: Verify owned source notices and rejection paths
+ run: |
+ python3 .github/scripts/test-source-headers.py
+ python3 .github/scripts/check-source-headers.py
- run: npm run check
- run: npm run bundle
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index e22af6e..52393ab 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
# Pins, inputs and analysis checks preserved; report validation now fails closed.
# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on
@@ -87,7 +90,7 @@ jobs:
global-json-file: global.json
- name: Initialize CodeQL
- uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
+ uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) && 'none' || inputs.build-mode }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index ac44f5b..49cd59e 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
name: codeql
on:
pull_request:
diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml
index a052210..b43d1fa 100644
--- a/.github/workflows/pr-gates.yml
+++ b/.github/workflows/pr-gates.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 6bbb21f..020c4b7 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -1,3 +1,6 @@
+# Copyright 2026 Kingsrook, LLC
+# SPDX-License-Identifier: Apache-2.0
+
name: security
on:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66c45e5..1a2d718 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,38 @@
# Changelog
+## 0.3.0-rc.1 — 2026-10-31
+
+Upgrade the server before the matching Windows connector. Native printing API
+2.0 requires synchronized authenticated WSS; the wire payload version remains 1.
+
+- Define printing semantic extension 2.0 with an authenticated outbound WebSocket
+ for direct controls, events, receipts and job notifications; prohibit polling
+ fallback while retaining existing wire-v1 domain payloads.
+- Mark the mandatory channel prerequisite for native claims as a breaking API
+ change from extension 1.0; framework `/qqq/v1` and the v1 subprotocol stay unchanged.
+- Specify durable reconnect reconciliation, exact ACK replay and independent
+ same-origin HTTPS streaming for large documents.
+- Add validated administrator settings and declared client ceilings for document
+ sizes, copies, queues, concurrency, bandwidth, liveness and timeouts, including
+ the native submission deadline.
+- Add strict frame/settings schemas, synthetic fixtures and negative validation.
+ Consumer transport, installed UI and physical output acceptance remain separate.
+
+## 0.2.0-rc.1 — 2026-10-31
+
+- Add optional wire-v1 device-control discovery and independent control polling.
+- Specify server dispatch/connection controls, irreversible credential revocation,
+ inventory refresh, queued-only cancellation, and exact owned spool cancellation.
+- Define expiring commands, durable replayable receipts, bounded history, and
+ timestamped admin requests that cannot execute again after retention pruning.
+- Add synthetic schema fixtures and invalid-input checks; physical output and
+ consumer implementation acceptance remain separate gates.
+- Specify the native semantic printing API, standard QQQ session authorization,
+ secondary bound-device proof and authoritative session identity fixtures.
+- Require bound devices to use native user sessions on every device route;
+ legacy compatibility cannot downgrade secondary proof into primary authority.
+ Native hosts disable legacy enrollment by default.
+
## 0.1.4-rc.1 — 2026-10-30
- Gate publication on security checks, promote the tested archive and verify packaged checksums.
@@ -13,7 +46,6 @@
- Existing requests remain valid; incompatible ZPL media, null/unknown profiles and missing request identity are rejected.
- Schema gate covers 29 definitions and 22 fixtures, including explicit 300-dpi test requests.
-
## 0.1.2-rc.1 — 2026-10-30
- Optional v1 heartbeat device profile and printer display-name/description/location fields, preserving physical queue names and identifiers.
diff --git a/README.md b/README.md
index a61af0c..927d43d 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# QQQ Print Protocol v1
-Shared contract for the [QQQ 4.1+ printing QBit](https://github.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent). The first implementation uses outbound HTTPS polling. WebSockets are unnecessary for this happy path. This contract is authoritative for wire names; language types may use their idiomatic naming.
+Shared contract for the [QQQ 4.1+ printing QBit](https://github.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent). The coordinated [realtime extension 2.0](docs/REALTIME.md) uses a client-initiated authenticated WebSocket for controls, receipts and job notifications, with separate streamed HTTPS documents and no polling fallback. This contract is authoritative for wire names; language types may use their idiomatic naming.
-Source distribution version: **0.1.4-rc.1** (unreleased audit update). Wire protocol: **v1**. This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
+Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1). Previous distribution: [**0.2.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.2.0-rc.1). Wire protocol: **v1**. This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
Use Node.js 24+ to run the development checks:
@@ -23,9 +23,11 @@ The release archive and `SHA256SUMS` are written to `dist/`. Schema/fixture cons
## Conventions
-Base path: `/printing/v1`. JSON uses UTF-8, camelCase properties, uppercase enum strings, and RFC 3339 UTC timestamps. IDs are opaque case-sensitive strings using URL-safe characters. Consumers ignore unknown response fields for compatible additions. Breaking changes require a new path version. Configure the complete server origin; production requires HTTPS. Explicit localhost-only development may use HTTP.
+The realtime implementation requires printing semantic extension `>=2.0.0 <3.0.0`; wire payload version remains `1`. Its settings, frames, replay rules and required consumer acceptance are in [REALTIME.md](docs/REALTIME.md). Upgrade the server before the matching Windows connector; installed and physical-output acceptance remain separate from this contract release. The HTTP polling sequence below documents the earlier 1.0/explicit compatibility transport, not a fallback for a realtime client.
-Use `Content-Type: application/json` except the content-download response. All authenticated endpoints require `Authorization: Bearer `. Device tokens and admin credentials are distinct; a device token must never authorize `/admin` operations. Enrollment uses only the single-use code in its request body. Never put credentials in query strings or log them.
+Primary native base: `/qqq/v1/qbit/printing`, discovered as QQQ semantic extension `printing`, using standard QQQ user authentication and secondary device proof. Realtime requires extension `2.0.0`; the earlier HTTP transport uses `1.0.0`. See [QQQ authentication](docs/QQQ-AUTHENTICATION.md). The routes below describe explicit legacy compatibility at `/printing/v1`; native authentication failure never silently selects that transport. JSON uses UTF-8, camelCase properties, uppercase enum strings, and RFC 3339 UTC timestamps. IDs are opaque case-sensitive strings using URL-safe characters. Consumers ignore unknown response fields for compatible additions. Breaking changes require a new major contract. Configure the complete server origin; production requires HTTPS. Explicit localhost-only development may use HTTP.
+
+Use `Content-Type: application/json` except the content-download response. The explicit legacy endpoints below use `Authorization: Bearer `. Device tokens and admin credentials are distinct; a device token must never authorize `/admin` operations. Legacy enrollment uses only the single-use code in its request body. Never put credentials in query strings or log them.
The host application authenticates management requests and determines authorized tenant/facility scope. A standalone local test host may use a configured administrative bearer credential. Device ownership, tenant and facility are taken from server records, not trusted request fields.
@@ -49,6 +51,13 @@ The host application authenticates management requests and determines authorized
Device lists and job lists are scoped by host authorization. Listing pagination and destination/profile CRUD are beyond this first wire contract. Native application code may submit through the QBit service rather than the management HTTP API.
+The additive [device-control contract](docs/DEVICE-CONTROLS.md) defines capability
+discovery, dispatch pause/resume, operational disconnect/reconnect, permanent
+credential revocation, inventory refresh, queued-job clearing, and exact owned
+Windows spool cancellation. It uses durable request identities, bounded command
+polling, and explicit receipts. These additions are included in the
+`0.2.0-rc.1` distribution, with 42 definitions and 54 validated fixtures.
+
## DTOs
`schemas/protocol.schema.json` defines the DTOs under `$defs`. Validate a message against the corresponding `#/$defs/`. Example request/response files in `fixtures/` use synthetic identities and nonworking illustrative credentials only.
@@ -69,7 +78,7 @@ Device lists and job lists are scoped by host authorization. Listing pagination
Formats: `PDF` (`application/pdf`) and `ZPL` (`application/vnd.zebra-zpl`). Content downloads may use `application/octet-stream`; the authenticated job's format is authoritative. ZPL content is UTF-8 printer-ready data. Printer status is `READY`, `OFFLINE`, `PAUSED`, `ERROR`, or `UNKNOWN`. It is a driver observation, not proof that hardware is ready. Formats are advertised adapter capabilities; do not infer ZPL support for every Windows queue.
-## Happy-path sequence
+## Historical HTTP happy-path sequence
1. Administrator creates an enrollment and enters its code plus server URL in the connector.
2. Connector exchanges the code once and securely retains the device identity/token.
diff --git a/docs/DEVICE-CONTROLS.md b/docs/DEVICE-CONTROLS.md
new file mode 100644
index 0000000..79d08d7
--- /dev/null
+++ b/docs/DEVICE-CONTROLS.md
@@ -0,0 +1,219 @@
+# Device controls — additive wire v1
+
+For the coordinated printing extension 2.0 implementation, these domain state,
+command and receipt semantics use [direct realtime frames](REALTIME.md).
+The HTTP polling routes below describe the earlier 1.0/explicit compatibility
+transport and are never a fallback for a realtime client.
+
+This extension provides server dispatch controls and two bounded Windows agent
+commands. Its additive payloads preserve existing print job states and old
+request bodies. These domain controls were published in package `0.2.0-rc.1`;
+their realtime transport is part of release candidate `0.3.0-rc.1`. This contract does
+not establish deployment or production readiness.
+
+## Discovery and routes
+
+The primary transport is the discovered QQQ semantic extension at
+`/qqq/v1/qbit/printing`, extension version `1.0.0`, using channel
+`api/qqq-print-agent`. Standard QQQ session authentication and current user,
+tenant, table, process and record permissions apply. Device calls additionally
+send `X-QQQ-Device-Id` and `X-QQQ-Device-Token`; that secondary proof does not
+replace the user session. See [QQQ authentication](QQQ-AUTHENTICATION.md).
+
+Paths below are relative to that primary base, except `/version`, which describes
+legacy wire discovery. Primary discovery uses `/qqq/v1/version` and its printing
+extension advertisement. Compatibility transport retains `/printing/v1` with
+host-authorized administrator scope and device bearer credentials. Compatibility
+is explicit and is never a silent fallback for a missing or denied native route.
+Neither request bodies nor cursor contents grant authority.
+
+Legacy device-token authentication is valid only for eligible unbound migration
+identities. Account-bound devices must use native user sessions; a bound token
+alone is denied even when the legacy endpoint remains registered. Native hosts
+disable legacy enrollment unless a legacy-only deployment explicitly enables it.
+
+| Method and path | Request | Response |
+| --- | --- | --- |
+| GET `/version` | None | `ProtocolVersionResponse` |
+| POST `/device/control/poll` | `DeviceControlPollRequest` | `DeviceControlPollResponse` |
+| POST `/device/control/commands/{commandId}/receipt` | `DeviceCommandReceipt` | `Acknowledgment` |
+| POST `/admin/devices/{deviceId}/actions` | `AdminDeviceActionRequest` | `DeviceActionRecord` |
+| GET `/admin/devices/{deviceId}/commands` | Optional `limit`, `before` | `DeviceActionList` |
+| GET `/admin/devices/{deviceId}/commands/{commandId}` | None | `DeviceActionRecord` |
+
+All successful responses use HTTP 200. Version discovery adds optional
+`capabilities:["device-control-v1"]`; `protocolVersion` remains integer `1`.
+For explicit compatibility transport, an older version response without that capability establishes only
+legacy support. A first poll returning 404 can establish legacy absence when the
+server did not advertise the capability. A timeout, 401/403, 5xx, or malformed
+response cannot establish legacy support. After control support is established,
+a missing capability or 404 is a failure, never permission to bypass saved state.
+Native printing extension `1.0.0` includes these controls; a native-route 404 is
+always a configuration or compatibility failure, never legacy discovery.
+
+The new poll reports `controlVersion:1`, `agentVersion`, `capabilities`, and
+`lastAppliedRevision`. The two capability names are `REFRESH_INVENTORY` and
+`CANCEL_SPOOL`; an empty list is valid for a state-only agent. A successful poll
+establishes state-control support independently of these command capabilities.
+An admin remote command unsupported by the last negotiated capabilities returns
+409 `CONTROL_UNSUPPORTED`. A capable but offline device may have a pending
+command; acceptance does not establish delivery or execution.
+
+The optional management `Device.control` contains `supported`, `capabilities`,
+optional `lastPollAt`, `lastAppliedRevision`, and `desiredState`. Missing control
+metadata means support is unknown. `lastPollAt` and the observed revision must be
+shown separately from the desired state; neither software version nor a saved
+capability proves that an offline agent applied a new setting.
+
+## Desired state and ongoing work
+
+`desiredState` contains `revision`, `dispatchPaused`, and `connectionState`, whose
+values are `CONNECTED` and `DISCONNECTED`. Initial state is revision 0, false,
+and `CONNECTED`. Changes are committed atomically with their action record and
+advance the revision. Revisions are nonnegative JSON-safe integers, at most
+9007199254740991. A device reports a revision only after applying and durably
+retaining that state; it cannot acknowledge a future server revision. The agent
+must not replace newer persisted state with an older poll response.
+
+The control loop runs independently of printing. Pausing or disconnecting gates
+new claims and dispatch. It does not cancel CLAIMED or later work, erase local
+journals, withdraw exposure selections, or discard in-flight job events. Claims
+and server state changes are serialized so work claimed before the change remains
+in flight. Before first synchronization or after restart, the agent retains the
+last state and withholds new claims until valid state or explicit legacy support
+has been established.
+
+`DISCONNECTED` stops ordinary heartbeat, inventory publication, and new claims.
+Authenticated control polling, command receipts, and in-flight job events remain
+available, so `RECONNECT` can reach the device. `REFRESH_INVENTORY` waits for
+`CONNECTED` or expires; it must not report success without publishing inventory.
+Explicit `CANCEL_SPOOL` remains available over the control path while disconnected.
+Reconnecting does not clear `dispatchPaused`; resuming does not change connection
+state. These are independent controls.
+
+Revocation invalidates the old credential permanently and blocks device routes,
+including control polling. Resume and reconnect cannot restore it. Re-enrollment
+uses the separate existing enrollment flow and a new local connection scope.
+401/403 blocks ordinary traffic and requires authorization recovery; a generic
+authentication failure is not proof of revocation. Keep the journal and receipts,
+report authorization required, and do not automatically re-enroll or erase state.
+
+## Administrator actions and replay
+
+`AdminDeviceActionRequest` requires `requestId`, `issuedAt` (RFC 3339 UTC ending
+in `Z`), and `action`. Only `CANCEL_SPOOL` accepts and requires `jobId`. No action
+accepts a queue name, numeric spool ID, executable, shell text, service name, or
+operating-system command. Unknown action names and request fields are rejected.
+
+| Action | Authoritative effect |
+| --- | --- |
+| `PAUSE_DISPATCH` | Set desired `dispatchPaused:true` and block new server claims. |
+| `RESUME_DISPATCH` | Set desired `dispatchPaused:false`; preserve connection and revocation state. |
+| `DISCONNECT` | Set desired `connectionState:DISCONNECTED` and block new server claims. |
+| `RECONNECT` | Set desired `connectionState:CONNECTED`; preserve dispatch pause and revocation state. |
+| `REVOKE` | Permanently invalidate the old credential. |
+| `CLEAR_QUEUE` | Atomically cancel only currently QUEUED jobs for this device. |
+| `REFRESH_INVENTORY` | Queue an agent command to discover and publish its locally exposed queues. |
+| `CANCEL_SPOOL` | Queue a cancellation request for the exact owned job identified by `jobId`. |
+
+The first five actions and queue clear complete on the server. Their record has
+`status:SUCCEEDED` and result code `DESIRED_STATE_UPDATED`, `CREDENTIAL_REVOKED`,
+or `QUEUED_JOBS_CANCELLED`, respectively. These results do not assert remote
+execution. Queue clear also returns `cancelledJobCount`. It preserves history,
+records cancellation using the existing FAILED job status, and never modifies a
+CLAIMED or later job. Replaying its action must not cancel jobs queued afterward.
+
+Scope request IDs to the authorized tenant and device. Look up the existing
+request before checking its age. Identical input, including `issuedAt`, returns
+the original record without another effect. Reusing that ID with changed input
+returns 409. Commit the action record and its server effect or pending command
+atomically before success. A transport retry retains the entire request body.
+
+For an absent request ID, `issuedAt` must be no more than 15 minutes old and no
+more than 60 seconds in the future, using server time. A stale request returns
+410; a future timestamp outside the allowance is invalid (400). Never refresh
+`issuedAt` automatically after a timeout or stale response. A newly reviewed
+action requires a new request ID and timestamp. IDs are never intentionally
+reused. Existing exact requests continue to replay within retained history even
+after their timestamp has aged out of the new-request window.
+
+At most 100 commands may remain pending per device. Retained action metadata is
+bounded to 1000 records per device, with one reserved revocation record so
+capacity cannot prevent credential revocation. Reclaim eligible terminal history
+before rejecting new work with 429. Terminal history may be removed after seven
+days measured from server-recorded termination; preserve unexpired or unfinished
+records. Old unchanged requests still fail the timestamp check after pruning and
+therefore cannot execute again. These limits affect control metadata, not job
+history or document retention.
+
+## Command delivery and receipts
+
+Remote actions initially return `PENDING`. `DeviceCommand` carries stable
+`commandId`, `requestId`, `action`, `createdAt`, `expiresAt`, and, for spool cancel,
+`jobId`. Server-created expiry is 15 minutes after creation. One poll returns at
+most 20 commands and `serverTime`, `pollIntervalSeconds` (1–300), and the full
+desired state. Pending commands may be delivered repeatedly until a receipt or
+expiry; they are not new commands on every poll. Do not deliver expired commands.
+
+The agent journals command identity and execution state before a side effect.
+Use the current enrollment/connection scope. It checks expiry before beginning,
+with server time available to avoid silently trusting a skewed local clock.
+A completed command returns its original stored receipt on duplicate delivery.
+After an interrupted cancellation whose outcome is uncertain, persist and report
+`OUTCOME_UNKNOWN`; never run the cancellation again speculatively. Receipt
+retransmission has no printing or cancellation side effect.
+
+`DeviceCommandReceipt` requires stable `receiptId`, `outcome`, and `completedAt`;
+optional `message` is at most 1024 characters. Persist the full receipt before
+transport. Exact replay returns `{ "accepted": true }`; a conflicting terminal
+receipt returns 409. The command ID in the route is the authority binding; receipts
+for another device's command are refused without disclosing it. The server
+validates that an outcome is meaningful for the original command.
+
+| Outcome | Permitted command | Record status |
+| --- | --- | --- |
+| `INVENTORY_REFRESHED` | `REFRESH_INVENTORY` | `SUCCEEDED` |
+| `CANCEL_REQUESTED` | `CANCEL_SPOOL` | `SUCCEEDED` |
+| `ALREADY_ABSENT` | `CANCEL_SPOOL` | `SUCCEEDED` |
+| `OWNERSHIP_MISMATCH` | `CANCEL_SPOOL` | `FAILED` |
+| `UNSUPPORTED`, `FAILED` | Either | `FAILED` |
+| `EXPIRED` | Either | `EXPIRED` |
+| `OUTCOME_UNKNOWN` | Either | `OUTCOME_UNKNOWN` |
+
+An expired server record without a receipt means the deadline passed without
+confirmation; it does not prove that no side effect occurred. Accept the first
+valid late receipt and replace this provisional EXPIRED status with its actual
+outcome. Once a receipt exists it is immutable. A late receipt never authorizes
+redelivery or re-execution. History responses include the receipt when present.
+
+After `expiresAt` plus seven days, a 404 from this specific command's receipt
+endpoint can mean the server pruned its record. The agent may stop retransmitting
+that receipt, retaining its exact local outcome as transport-retired rather than
+server-acknowledged. This must not block newer commands or cause re-execution.
+A 404 before that bound, or from control polling, does not establish this case.
+
+Cancellation resolves the job from the agent's current connection journal and
+checks the exact printer, spool reference, and owned document identity before
+using Windows cancellation. Numeric spool IDs may be reused. Missing ownership
+proof must refuse cancellation, not fall back to a queue-wide operation. Report
+`ALREADY_ABSENT` only when the exact owned job is known and its spool entry is
+absent. An identity mismatch reports `OWNERSHIP_MISMATCH` without cancellation.
+`CANCEL_REQUESTED` means Windows accepted the request; it does not guarantee that
+paper, a label, or data already sent to hardware was recalled. Job event history
+remains intact; command outcomes do not fabricate physical print completion.
+
+## History, fixtures, and verification scope
+
+The command list includes server actions and remote commands, newest first.
+`limit` defaults to 50 and ranges from 1 to 200. `nextCursor` is null at the end;
+otherwise pass the bounded opaque cursor unchanged as `before`. Both list and
+detail routes enforce tenant and device scope. Responses contain control metadata
+and receipts, never credentials or document bytes.
+
+The JSON Schema defines all new messages. Synthetic fixtures cover all eight
+actions, both command types, disconnected polling, server-only results, every
+receipt outcome, bounded history, and legacy version discovery. `npm run check`
+checks structural constraints and representative invalid inputs. Atomicity,
+authorization, time-window enforcement, expiry races, crash recovery, and Windows
+ownership checks additionally require the server and agent integration suites;
+schema validation alone does not prove those behaviors.
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index ed65025..8ab1773 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -14,9 +14,23 @@ If a consumer builds outside a multi-checkout workspace, download the release ar
## Versioning
-Wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Distribution version `0.1.0-rc.1` identifies the specification/fixture bundle; it does not change that wire version.
+The coordinated realtime client requires printing semantic extension
+`>=2.0.0 <3.0.0` and the authenticated `qqq-printing.v1` WebSocket subprotocol.
+Wire payload version remains 1. [REALTIME.md](REALTIME.md) defines the new frames,
+configurable policy, streaming limits and runtime acceptance expectations.
+The release candidate is [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1).
+Upgrade the server before the Windows connector; fixtures do not establish installed behavior.
-Additive optional response fields can remain v1. Required new inputs, renamed fields, removed enum values or changed semantics require explicit compatibility design and usually a new path version. Feature negotiation may be added later; consumers must not assume unadvertised capabilities.
+Historical HTTP wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Published distribution version `0.2.0-rc.1` identifies the specification/fixture bundle; it does not change that wire version. The Windows and QBit release numbers are independent of both. Native clients use the discovered printing extension semantic version; the realtime contract above adds its explicit version/capability gate without redefining core discovery.
+
+Additive optional response fields can remain v1. Required new inputs on existing routes, renamed fields, removed enum values or changed semantics require explicit compatibility design and usually a new path version. Consumers must not assume unadvertised capabilities.
+
+The published `0.2.0-rc.1` extension adds explicit `device-control-v1` discovery and
+new control routes without adding required fields to existing requests. See the
+[device-control contract](DEVICE-CONTROLS.md) for state, commands, receipts,
+timestamped idempotency, and legacy behavior. Source fixtures do not establish
+consumer implementation or installation. Pin the reviewed protocol
+commit with matching server and Windows compatibility tests before shipping.
Claim acknowledgment means local receipt; SPOOLED means Windows accepted the document. Neither proves physical output. Cross-language compatibility tests should assert these distinctions as well as spelling, JSON casing, formats, option defaults and content integrity.
diff --git a/docs/QQQ-AUTHENTICATION.md b/docs/QQQ-AUTHENTICATION.md
new file mode 100644
index 0000000..06723b4
--- /dev/null
+++ b/docs/QQQ-AUTHENTICATION.md
@@ -0,0 +1,95 @@
+# Standard QQQ authentication
+
+The new Windows client uses the QQQ semantic UI/UX API. Package versions, wire
+payload version `1` and the printing extension semantic version are separate
+contracts. The standard-session contract was published in `0.2.0-rc.1`; realtime
+transport is part of release candidate `0.3.0-rc.1`. This document does not establish an
+accepted Authentik deployment.
+
+## Discovery and sign-in
+
+Require printing extension `>=2.0.0 <3.0.0` before opening the realtime channel
+or dispatching work. Standard discovery and sign-in may accept compatible 1.x
+and 2.x extensions; a successful 1.x login does not permit realtime dispatch or
+polling fallback. Published 0.2 clients used native printing extension 1.0 and
+cannot satisfy the new mandatory channel prerequisite. The standard descriptor
+is unchanged; `realtime-v1` is mandatory in WebSocket HELLO. See
+[realtime authentication and transport](REALTIME.md).
+
+Read public `/qqq/v1/version` before sending credentials. Require core semantic
+API version `>=1.8.0 <2.0.0`, the `api` channel and printing extension version
+`>=1.0.0 <3.0.0` for the shared authentication client. Realtime operation still
+requires the stricter 2.x gate above.
+Use client channel `api/qqq-print-agent` and read
+`/qqq/v1/metaData/authentication`. Do not infer server authentication from a URL,
+brand, a saved token or a previous deployment.
+
+For `TABLE_BASED` authentication, sign in through standard `manageSession` with
+the supplied credentials. Retain the QQQ session cookie, never the password.
+For OAuth2, additionally require core API `>=1.9.0 <2.0.0`, the shared QQQ
+`nativeSessionLogin` capability and its typed `AUTHORIZATION_CODE_PKCE_SESSION`
+grant, S256, `prompt: "login"` and exact allowed redirect URI.
+Use the system browser, random state and PKCE, then exchange the authorization
+code through standard `manageSession`. Do not put a confidential OAuth client
+secret in Windows, substitute a public bearer client or invent a refresh API.
+Missing native grants require server configuration, not a fallback local login.
+
+The desktop callback must match the shared grant and installer registration.
+Windows uses the exact private callback
+`io.qrun.qqq-print-agent:/oauth/callback`; current central candidate policy rejects
+HTTP callbacks. The installer registers the callback to a non-elevated forwarding
+helper, which delivers it only to an active sign-in attempt in the same Windows
+user/session. State and PKCE validation remain mandatory. The implementation,
+shared grant and provider deployment must be qualified before Authentik acceptance.
+
+## User and device authority
+
+Protected printing routes are under `/qqq/v1/qbit/printing`. Every request needs
+the current QQQ session. Agent operations additionally send
+`X-QQQ-Device-Id` and `X-QQQ-Device-Token` only to the configured printing origin.
+Never forward these credentials to discovery, public assets or another origin.
+
+Enrollment at `/devices/enroll` validates the facility against the current
+user's allowed facilities and binds the new device to that authoritative QQQ
+principal. An existing unbound device uses explicit `/device/bind` with its
+device proof and a currently authorized session. `/session` returns authoritative
+`principalId` and `tenantId`; it includes `deviceId` only when the request also
+supplies validated secondary proof for that bound device. Client display names are not
+identity. A conflicting binding fails rather than silently transferring a device.
+
+Each subsequent agent request checks the bound principal, tenant and current
+record/process permissions. Administrative controls instead authorize the current
+administrator against the target device and affected jobs; administrators do not
+need another device's secret or have to impersonate its enrolled user. Generic
+table writes stay disabled. Domain operations perform their own permission and
+concurrency checks.
+
+## Unattended service sessions
+
+The service protects its session using Windows DPAPI and restricts access through
+the existing authenticated local service channel. Snapshots and logs must not
+contain cookies, passwords, device secrets or authorization codes. A token refresh
+must not discard the device journal or permit a completed job to print again.
+
+Resume through standard `manageSession` within the server cookie lifetime.
+OAuth provider refresh tokens remain encrypted on the QQQ server. Months or years
+of operation depend on server and provider renewal policy; no client credential
+guarantees indefinite login. Account disablement, permission changes, logout,
+session expiry and device revocation must remain effective. Fresh permission
+invalidation must be qualified against the shared core, not assumed from a cached
+session. Authorization failure blocks new printing while preserving journals and
+uncertain outcomes for explicit recovery.
+
+Legacy `/printing/v1` bearer transport remains explicit compatibility for old
+integrations. It is not the new client's primary security boundary, and failure
+of native discovery or authentication never silently activates it.
+
+Once a device is bound to a QQQ principal, its token must be rejected by legacy
+device routes. The same secondary proof cannot be used to bypass user permissions
+through an older endpoint. Existing unbound devices may remain eligible for an
+explicit migration policy until they are associated with an account.
+
+Native hosts disable legacy enrollment by default: an enrollment code cannot
+create a bearer-only identity through the old route. Legacy-only hosts may
+explicitly enable that separate compatibility flow. Native enrollment continues
+to require the current QQQ user, facility permissions and atomic account binding.
diff --git a/docs/REALTIME.md b/docs/REALTIME.md
new file mode 100644
index 0000000..f2106b3
--- /dev/null
+++ b/docs/REALTIME.md
@@ -0,0 +1,291 @@
+# Realtime printing — semantic extension 2.0
+
+This is the contract for the `0.3.0-rc.1` realtime release candidate.
+Contract publication does not establish installed acceptance. Existing job states, command outcomes,
+current-user authorization and durable idempotency rules remain authoritative.
+
+Native printing extension **2.0.0** makes an authenticated synchronized WebSocket
+a prerequisite for claiming jobs. Published extension 1.0 clients could claim
+over HTTP alone, so this changes existing behavior and requires a major version
+under [Semantic Versioning](https://semver.org/#spec-item-8). A coordinated upgrade
+does not make that change backward compatible. Product `0.3.0-rc.1`, framework
+`/qqq/v1`, wire payload v1 and subprotocol `qqq-printing.v1` remain independent.
+
+## Discovery and connection
+
+Require QQQ printing semantic extension `>=2.0.0 <3.0.0`, discovered through
+`/qqq/v1/version`, and the existing core API/channel/authentication gates.
+A 1.x server is upgrade-required for this client. Do not infer support from a
+socket accepting a connection, a saved version, or a prior enrollment.
+
+`printing-realtime-extension.discovery.json` shows the existing core `extensions`
+entry for printing 2.0.0, not a replacement schema for the core discovery document.
+Its descriptor uses the unchanged name `printing` and path prefix `/qbit/printing`.
+
+The client opens one persistent WebSocket to the configured QQQ origin:
+
+- Route: `/qqq/v1/qbit/printing/device/realtime`.
+- Subprotocol: `qqq-printing.v1`; the server must select this exact value.
+- Scheme: `wss`; `ws` is allowed only for explicitly configured loopback development.
+- Upgrade authentication: standard QQQ session cookie,
+ `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`.
+
+Do not add credentials to URLs, subprotocols, logs or frame payloads. Refuse
+cross-origin redirects and arbitrary content URLs. The existing session is
+primary authority; device proof never replaces it. See [QQQ authentication](QQQ-AUTHENTICATION.md).
+
+Authenticate the upgrade and revalidate the current session, principal/device
+binding, tenant and operation-specific permissions for each incoming operation
+and outgoing delivery. Revalidate on liveness traffic too; an idle connection
+cannot preserve expired or withdrawn authority indefinitely. Use the normal QQQ
+session-resume flow; provider refresh stays on the server. When the session
+credential changes, reconnect with the current credential while retaining the
+same authoritative device/journal scope. No private WebSocket token is created.
+
+There is one active authenticated connection per device. A new connection
+supersedes the old generation. A late close, ACK or callback from an older
+generation cannot close or acknowledge the replacement. A connection ID and
+message order identify transport ownership, never user authority.
+
+## Frames
+
+Only UTF-8 JSON text messages are accepted. Each complete message has
+`{ "type": "...", "messageId": "...", "payload": { ... } }`.
+Use the existing URL-safe `Id` alphabet and 200-character bound; use hyphens,
+not colons, in generated prefixes. Bound the reassembled message, not merely an
+individual WebSocket fragment, by `maxFrameBytes`. Binary/document messages and
+compression that could bypass decoded-size limits are not supported.
+
+| Direction | Type | Payload |
+| --- | --- | --- |
+| Client → server | HELLO | `agentVersion`, `capabilities`, `lastAppliedRevision`, `lastSettingsRevision`, `limits` |
+| Server → client | WELCOME | `connectionId`, `serverTime`, `desiredState`, `settings` |
+| Client → server | STATE_APPLIED | `lastAppliedRevision`, `lastSettingsRevision` |
+| 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 | COMMAND | Existing `DeviceCommand` directly |
+| Client → server | JOB_EVENT | `jobId`, existing `JobEvent` as `event` |
+| Client → server | COMMAND_RECEIPT | `commandId`, existing `DeviceCommandReceipt` as `receipt` |
+| Client → server | HEARTBEAT | Existing `HeartbeatRequest` directly |
+| Client → server | INVENTORY | Existing `PrinterInventory` directly |
+| Client → server | PING | Empty object; liveness only |
+| Server → client | PONG | `serverTime` |
+| Server → client | ACK | `acknowledgedMessageId` |
+| Server → client | ERROR | `code`, safe `message`, `retryable` |
+
+The settings payload schema is `RealtimeSettings`; its envelope schema is
+`RealtimeSettingsMessage`. The directional unions are `RealtimeClientMessage`
+and `RealtimeServerMessage`. An ERROR responding to a frame uses that frame's
+`messageId`; connection-level errors use a fresh ID. Never treat an ERROR as
+a successful ACK or erase the corresponding durable event/receipt.
+
+Canonical error codes include AUTHORIZATION_REQUIRED, CONFIGURATION_REQUIRED,
+INVALID_MESSAGE and OPERATION_FAILED. Preserve domain NOT_FOUND, EXPIRED and
+CONFLICT distinctions for 404/410/409 outcomes, including the existing bounded
+pruned-receipt retirement rule. Only transient failures set `retryable:true`;
+unknown codes remain explicit errors, never permission to fall back or drop work.
+
+HEARTBEAT is periodic small status/profile traffic over this socket. INVENTORY
+runs on initial synchronization, actual local changes or an explicit refresh
+command; it is not an HTTP polling loop. PING/PONG may maintain liveness without
+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.
+
+## Ordering, synchronization and replay
+
+1. Authenticate the connection, then require HELLO before other application work.
+ Its revision values report previously persisted observations, not authority.
+ Subscribe to committed changes before taking the initial durable snapshot.
+2. Send WELCOME with current settings and desired state. The client validates
+ policy and its declared ceilings, persists both revisions and values, applies
+ them, and sends STATE_APPLIED. Neither revision may roll back or exceed the
+ authoritative revision.
+3. Acknowledge STATE_APPLIED only after recording that observation for the active
+ connection. New claims require that active authorized connection and both
+ current revisions. The client also waits for that matching ACK before claims.
+ Remember an early JOB_AVAILABLE until this gate is satisfied.
+4. Deliver pending unexpired commands and a coalesced JOB_AVAILABLE for durable
+ queued work. Committed desired-state, settings, job and command changes trigger
+ delivery. Use a transaction/outbox or post-commit synchronization mechanism
+ that cannot lose a change between subscription, snapshot and the empty claim.
+5. JOB_AVAILABLE triggers a finite authenticated HTTPS claim drain until empty
+ or policy/concurrency capacity is reached. Keep a pending wake while at capacity;
+ lane completion resumes that drain. Reaching empty atomically clears the wake
+ relative to later committed enqueues. Returning to eligible state after
+ resume/reconnect or settings application also wakes the drain.
+6. Persist exact JOB_EVENT and COMMAND_RECEIPT envelopes before sending.
+ ACK means the same existing authorized service operation committed durably.
+ Replay identical message IDs, domain event/receipt IDs and payloads until ACK.
+ Lost ACKs must return success for the original committed result; changed
+ content for an existing identity conflicts.
+7. After network loss, reconnect using configured bounded exponential backoff
+ and jitter, repeat HELLO/WELCOME/current-state reconciliation, and replay the
+ durable outbox. The server replays pending commands from its durable store;
+ notifications and per-connection ordering are not a durable work ledger.
+
+No job, control, heartbeat or receipt HTTP timer is permitted in this transport.
+No timeout, 404, socket failure, empty response or unsupported version enables
+HTTP polling or bearer fallback. Existing compatibility routes remain explicit
+diagnostics for eligible identities, not this client's recovery path.
+An implementation that uses timed database polling as its notification source
+does not satisfy this contract. Multi-node hosts need shared committed-change
+delivery; an in-process event bus qualifies only its documented single-node scope.
+
+Job claims/content stay on their existing authenticated HTTPS routes at the same
+origin. A lost claim response must be reconciled with the authoritative assigned
+job and local journal on reconnect before claiming unrelated work; it cannot
+strand a CLAIMED job or authorize a second physical submission. Initial or
+event-triggered bounded HTTPS reconciliation snapshots are allowed, never timers.
+
+The existing command journal decides whether a repeated command is unexecuted,
+in progress or terminal. Persist execution intent before a native cancellation;
+after uncertain execution return OUTCOME_UNKNOWN, never blindly invoke it again.
+The existing receipt-retention and pruned-history rules still apply. A missing
+expired server receipt is not fabricated server acceptance.
+
+DISCONNECTED and dispatch pause gate new claims. DISCONNECTED retains the socket,
+desired-state updates, commands, receipts and in-flight job events, but suppresses
+ordinary HEARTBEAT/inventory publication; PING/PONG still maintains liveness.
+REFRESH_INVENTORY waits until CONNECTED or expires. RECONNECT does not clear pause,
+and neither control restores revoked credentials. No command restarts Windows,
+the service or a printer. A reported spool cancellation cannot recall paper.
+
+## Operational settings and client ceilings
+
+The server is authoritative. Administrators edit validated operational policy
+through native QQQ permissions; agents cannot set it. All 23 properties below,
+including the server-generated `revision`, are required in each complete settings
+snapshot. Never infer an omitted value from a compiled default.
+
+| Property | Default | Inclusive range |
+| --- | --- | --- |
+| `revision` | Server-generated | 0–9007199254740991 |
+| `maxCopies` | 100 | 1–32767 |
+| `maxPdfBytes` | 104857600 | 1–2147483647 |
+| `maxZplBytes` | 20971520 | 1–2147483647 |
+| `maxFrameBytes` | 1048576 | 8192–16777216 |
+| `maxQueuedFrames` | 64 | 8–4096 |
+| `maxQueuedFrameBytes` | 4194304 | 8192–67108864 |
+| `maxConcurrentDownloads` | 1 | 1–16 |
+| `maxConcurrentPrints` | 1 | 1–16 |
+| `maxStagedBytes` | 536870912 | 1–9007199254740991 |
+| `maxDownloadBytesPerSecond` | 0 | 0–2147483647 |
+| `requestTimeoutMillis` | 30000 | 1000–300000 |
+| `downloadConnectTimeoutMillis` | 30000 | 1000–300000 |
+| `downloadIdleTimeoutMillis` | 30000 | 1000–3600000 |
+| `downloadTimeoutMillis` | 600000 | 1000–86400000 |
+| `webSocketHandshakeTimeoutMillis` | 30000 | 1000–300000 |
+| `heartbeatIntervalSeconds` | 20 | 5–300 |
+| `heartbeatTimeoutSeconds` | 60 | 10–900 |
+| `reconnectMinDelayMillis` | 1000 | 100–60000 |
+| `reconnectMaxDelayMillis` | 30000 | 1000–300000 |
+| `reconnectJitterPercent` | 20 | 0–50 |
+| `ackTimeoutMillis` | 15000 | 1000–300000 |
+| `nativeSubmissionTimeoutMillis` | 120000 | 1000–86400000 |
+
+Bytes are exact integers; 100 MiB PDF and 20 MiB ZPL are recommendations, not
+immutable content limits. `maxDownloadBytesPerSecond:0` explicitly means
+unlimited aggregate download bandwidth. Jitter is applied within the configured
+minimum/maximum reconnect bounds, including at the cap. No setting authorizes
+an arbitrary URL, OS operation or widening of a user's permissions.
+
+Validate these relationships before committing or applying policy:
+
+- `maxQueuedFrameBytes >= maxFrameBytes`.
+- `maxStagedBytes >= max(maxPdfBytes, maxZplBytes)`.
+- `downloadTimeoutMillis >= downloadConnectTimeoutMillis` and
+ `downloadTimeoutMillis >= downloadIdleTimeoutMillis`.
+- `heartbeatTimeoutSeconds >= 2 * heartbeatIntervalSeconds`.
+- `reconnectMaxDelayMillis >= reconnectMinDelayMillis`.
+- Each corresponding setting is at most the declared client ceiling.
+
+HELLO `limits` declares the client's actual supported maxima for
+`maxCopies`, `maxPdfBytes`, `maxZplBytes`, `maxFrameBytes`, `maxQueuedFrames`,
+`maxQueuedFrameBytes`, `maxConcurrentDownloads`, `maxConcurrentPrints`,
+and `maxStagedBytes`. It is not a client request to lower server policy.
+Reject incompatible policy with CONFIGURATION_REQUIRED; never silently clamp or
+use a lower intersection. Concurrency must be an actual configurable bounded
+pool, with separate per-printer serialization, not a permanently serial
+implementation advertising a configurable pool.
+
+A committed policy revision is pushed as SETTINGS. Apply and durably acknowledge
+it before new work under that revision. Existing transfers retain their admitted
+resource reservation and a consistent timeout/size snapshot; reducing limits
+blocks further admission until usage fits, rather than discarding receipts or
+reclassifying already submitted output. Queue/frame limits apply to both count
+and bytes. Reserve capacity for acknowledgments/liveness; coalesce replaceable
+state/settings/job notifications, but never drop durable events or receipts.
+An overfull queue produces bounded backpressure/reconnect and durable replay.
+
+Existing admission and retention policies keep their established names and
+scopes; do not duplicate them as per-device transport defaults. Host/tenant
+admission comprises `deviceOutstandingJobs`, `tenantOutstandingJobs`,
+`deviceOutstandingBytes`, `tenantOutstandingBytes`, `deviceRetainedBytes`,
+`tenantRetainedBytes`, `deviceJobsPerHour`, `tenantJobsPerHour`,
+`devicePrinters` and `tenantPrinters`. Retention uses `enabled`, `days` and
+`batchSize`. Their effective values and scope belong in authorized settings
+metadata. Intrinsic protocol, renderer, pipe and operating-system ceilings must
+be disclosed separately from editable operational policy. No implementation
+may claim that this realtime settings object alone exposes every existing host
+or Windows setting.
+
+## Document transfer and failure boundaries
+
+Large documents never enter WebSocket messages, JSON/base64 control buffers or
+whole-file memory buffers. Stream the existing same-origin HTTPS content route
+to a restricted temporary file with incremental SHA-256, actual byte counting,
+format validation and a staging reservation. Validate declared length and format
+against the effective PDF/ZPL policy before transfer; enforce actual bytes while
+streaming, including absent or false Content-Length and decompression behavior.
+Only verified complete content may be submitted to Windows.
+
+Enforce configured connect, idle and total deadlines and aggregate bandwidth
+across the bounded download pool. Dispose/cancel streams and remove only owned
+temporary files on failure, retaining job identity/outcome evidence. A throttled
+download must not consume the control reader, writer or command worker. Do not
+start another download without its byte reservation or release reserved space
+while a renderer still uses the file. No automatic retry may duplicate a
+possibly submitted print.
+
+Apply `nativeSubmissionTimeoutMillis` to the isolated native printer submission
+using the admitted job's policy snapshot. A timeout does not prove that Windows
+accepted no bytes; preserve uncertain output and never automatically submit it
+again. A larger configured document limit must not leave a hidden fixed native
+submission deadline in the consumer.
+
+Authentication/permission failure blocks new work and preserves journal state.
+A generic denial is not proof that the device was revoked. Protocol errors,
+oversized messages and overload must fail explicitly; never treat them as an
+empty queue. Close/reconnect transport without silently switching authentication.
+Native HTTP calls continue to revalidate current user and device authority.
+
+## Required consumer acceptance
+
+Schema/fixture checks establish spelling, bounds and examples, not runtime or
+hardware qualification. Both implementations must demonstrate these behaviors
+with isolated real transports before release:
+
+| Scenario | Required observation |
+| --- | --- |
+| Slow large PDF | Hold an HTTPS response mid-body; receive/apply a new desired-state revision and complete an unrelated owned command/receipt ACK before releasing the body. Liveness continues; resident memory does not scale with document length. |
+| Concurrent transfers | Set download/print concurrency above one; observe bounded actual parallel transfers, aggregate bandwidth/staging limits and per-printer serialization. Restore the configured default of one without dropping admitted work. |
+| Lost command receipt ACK | Commit receipt, drop ACK, reconnect and replay exact envelope; one native cancellation invocation, one durable result, replay ACK succeeds. |
+| Interrupted cancellation | Lose the connection/process after durable execution intent but before a known result; preserve OUTCOME_UNKNOWN and do not issue the native operation twice. |
+| Queued work while offline | Commit jobs and controls while the socket is absent; reconnect snapshot/pending-command replay and one job wake drain all eligible queued work without a timer or duplicate print. |
+| Empty-claim/enqueue race | Enqueue after the last empty query while ending a drain; a committed wake is retained and the new job is claimed without polling. |
+| Lost claim response | Disconnect after server claim commit; reconcile assigned work and the journal before further claims, without stranding or reprinting it. |
+| Lost event ACK | Commit SPOOLED then drop ACK; the identical replay fixture is acknowledged without another spool submission. |
+| Policy change | Push validated new settings during a stalled transfer; control remains responsive, new claims wait for both revisions, and existing reservations remain bounded. |
+| Policy/ceiling refusal | Reject missing/fractional/out-of-range/cross-field-invalid values and a policy above declared client capability; no silent defaults or clamping. |
+| Authorization loss | Revoke session/device or withdraw current permission while idle and busy; reject further delivery/claims, preserve pending evidence and never use bearer fallback. |
+| Connection replacement | Replace a socket, then deliver old-generation ACK/close callbacks; replacement ownership and pending messages remain correct. |
+| Inventory/frame bounds | Exercise the maximum configured inventory and fragmented/oversized control messages; fail explicitly before unbounded allocation, never interpret partial inventory as withdrawal. |
+| Notification source | Demonstrate after-commit enqueue/control/settings wake-ups and reconnect reconciliation with no periodic HTTP or database job/control polling. |
+
+Physical output still requires user/hardware confirmation. SPOOLED and
+SPOOLER_COMPLETED retain their existing meanings.
diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md
index ebf97e7..f927a75 100644
--- a/docs/SESSION-STATE.md
+++ b/docs/SESSION-STATE.md
@@ -1,4 +1,70 @@
-# Session state — 2026-10-30
+# Session state — 2026-10-31
+
+## Realtime contract implementation
+
+Isolated branch `feat/realtime-printing-20261031` defines printing extension 2.0,
+authenticated client-initiated WSS, direct controls/events/receipts and streamed
+same-origin HTTPS documents without job/control polling or fallback. The frozen
+settings include a default single configurable download lane, 100 MiB PDF,
+20 MiB ZPL, 100 copies and a configurable 120-second native submission deadline,
+with declared client ceilings and explicit validation.
+Slow-transfer control delivery, lost-connection replay, claim reconciliation and
+no-duplicate-submission checks are required consumer acceptance, not claims made
+by schema validation. Release candidate `0.3.0-rc.1` is described in
+[the complete contract](REALTIME.md); this source checkpoint records no live changes.
+
+The native API major is 2 because an authenticated synchronized WebSocket is now
+mandatory for claims that 1.0 clients could previously make over HTTP alone.
+Product 0.3, framework `/qqq/v1`, wire payload v1 and the v1 subprotocol remain
+unchanged. A new fixture covers the standard printing extension descriptor;
+historical legacy discovery fixtures remain unchanged.
+
+Locked offline dependency restore, `npm run check` and `npm run bundle` passed
+for this source: 71 JSON Schema definitions and 73 synthetic fixtures, including
+the final native submission deadline, all 23 required settings, invalid policy
+bounds, directional frames and exact replay. The eight changed Markdown documents
+passed Markdown lint; `git diff --check` passed. The local archive is preparation
+only; both consumers pin reviewed protocol commit `df3729ac632d4a34a3fb8423acef465042a624fc`.
+Tag publication and asset provenance must be checked on the release workflow.
+
+## Historical 0.2 implementation checkpoint — standard-session transport
+
+The new Windows client uses discovered semantic extension `printing` version
+`1.0.0` at `/qqq/v1/qbit/printing`, channel `api/qqq-print-agent`, with standard
+QQQ sessions and secondary device proof. Legacy bearer transport is explicit
+compatibility only. See `QQQ-AUTHENTICATION.md` for current-user/device binding,
+server-owned refresh and the exact private desktop callback. Authentication and
+consumer integration gates are pending; schema validation is not browser-login
+or deployment acceptance. Root added the authoritative printing-session identity
+contract and verified all 42 definitions and 54 fixtures successfully. The native
+authentication boundary also rejects bound device tokens on legacy routes and
+disables legacy enrollment by default in native hosts; only eligible unbound
+identities retain explicit migration compatibility. This clarification changes
+no schema, fixture or consumer pin.
+
+## Historical 0.2 implementation checkpoint — device-control review
+
+Isolated branch `feat/server-device-controls-20261031` adds the frozen wire-v1
+control contract in `docs/DEVICE-CONTROLS.md`. Server and Windows owners received
+the same route, action, state, and receipt names. Admin requests require stable
+`requestId` plus `issuedAt`; the age check prevents stale re-execution after
+terminal history expires. Permanent revocation, desired-state observation,
+queued-only clear, and exact owned spool cancellation remain distinct.
+
+Node 24.22.0 local `npm run check` passed 41 schema definitions and 52 fixtures,
+including legacy discovery and invalid control targets, commands, timestamps,
+capabilities, response bounds, and receipt claims. The strict schema gate first
+caught a missing branch-local property declaration; it was corrected before the
+passing run. These are protocol checks, not live consumer or printer acceptance.
+The proposed package version is `0.2.0-rc.1`; package metadata remains at the
+published version pending coordinated review. No commit, push, release, install,
+network service call, or physical print occurred in this work.
+
+## Published release checkpoint — 2026-10-31
+
+The compatible contract bundle [v0.1.4-rc.1](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.1.4-rc.1) is published. Earlier candidate and publication-pending entries below describe their original checkpoint. The coordinated Windows and QBit audit prereleases are also published; machine installation, local-host acceptance, physical output and production storage qualification have their own evidence and must not be inferred from this contract release.
+
+The wire remains `/printing/v1` with integer protocolVersion validation. Distribution SemVer does not provide runtime feature negotiation. No API behavior or schema changed in this documentation follow-up.
## Audit candidate 0.1.4-rc.1
diff --git a/docs/SOURCE-HEADERS.md b/docs/SOURCE-HEADERS.md
new file mode 100644
index 0000000..bde6a98
--- /dev/null
+++ b/docs/SOURCE-HEADERS.md
@@ -0,0 +1,17 @@
+# Owned source notice check
+
+Run these commands from the repository root with the existing Python 3 interpreter:
+
+```text
+python3 .github/scripts/test-source-headers.py
+python3 .github/scripts/check-source-headers.py
+```
+
+The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files.
+
+Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails.
+
+JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
+
+The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
+This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard.
diff --git a/docs/TODO.md b/docs/TODO.md
index 49e5789..9f6a4fd 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,35 @@
# TODO
+## Realtime 0.3.0-rc.1 release candidate
+
+- [x] Freeze printing extension 2.0, authenticated realtime frames and independent
+ streamed HTTPS documents with no polling fallback.
+- [x] Define all 23 required settings, including configurable native submission
+ timeout, client ceilings and explicit policy refusal.
+- [x] Pass 71 schema definitions, 73 fixtures, local bundle creation, Markdown
+ lint and whitespace checks without changing runtime or publishing assets.
+- [x] Review and commit the protocol, then pin that immutable commit in both
+ consumer CI workflows.
+- [ ] Complete consumer transport/replay, slow-transfer control, concurrency and
+ authorization acceptance before release; schema checks do not establish them.
+- [ ] Complete installed UI and physical output acceptance separately.
+
+## Historical 0.2 and earlier implementation checkpoints
+
+The entries below retain their original checkpoint status. They do not describe
+the current realtime release's completion or publication.
+
+- [ ] Qualify semantic discovery, current-user authorization, device binding,
+ session renewal and the shared native PKCE callback in both consumers.
+
+- [x] Freeze additive device-control routes, desired state, actions and receipts
+ with both consumer owners; document bounded timestamped idempotency.
+- [x] Validate 41 definitions and 52 synthetic fixtures with Node 24.22.0.
+- [ ] Review matching server and Windows state, authority, replay, expiry, and
+ exact spool-ownership tests before adopting the new immutable protocol pin.
+- [ ] Coordinate proposed `0.2.0-rc.1` version and release after feature gates;
+ no production 1.0 or installation qualification follows from schema checks.
+
- [ ] Verify security-gated artifact promotion and public build provenance for 0.1.4-rc.1.
- [x] Audit PR #2 at 7f3fa6b passes all hosted checks; downloaded CI archive checksum verified. Tagged release and published attestation remain pending.
- [x] Shared wire v1 remains compatible during the retention/storage and Windows audit fixes, including optional bounded printer pagination.
diff --git a/fixtures/action-cancel-spool.request.json b/fixtures/action-cancel-spool.request.json
new file mode 100644
index 0000000..970dd45
--- /dev/null
+++ b/fixtures/action-cancel-spool.request.json
@@ -0,0 +1,6 @@
+{
+ "requestId": "action-cancel-spool-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "CANCEL_SPOOL",
+ "jobId": "job-example-1"
+}
diff --git a/fixtures/action-cancel.response.json b/fixtures/action-cancel.response.json
new file mode 100644
index 0000000..20eb30c
--- /dev/null
+++ b/fixtures/action-cancel.response.json
@@ -0,0 +1,11 @@
+{
+ "commandId": "command-cancel-1",
+ "requestId": "action-cancel-spool-1",
+ "action": "CANCEL_SPOOL",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "jobId": "job-example-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "status": "PENDING"
+}
diff --git a/fixtures/action-clear-queue.request.json b/fixtures/action-clear-queue.request.json
new file mode 100644
index 0000000..6d93e2c
--- /dev/null
+++ b/fixtures/action-clear-queue.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-clear-queue-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "CLEAR_QUEUE"
+}
diff --git a/fixtures/action-clear-queue.response.json b/fixtures/action-clear-queue.response.json
new file mode 100644
index 0000000..67d34be
--- /dev/null
+++ b/fixtures/action-clear-queue.response.json
@@ -0,0 +1,12 @@
+{
+ "commandId": "command-clear_queue-1",
+ "requestId": "action-clear-queue-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "CLEAR_QUEUE",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "QUEUED_JOBS_CANCELLED",
+ "cancelledJobCount": 7
+}
diff --git a/fixtures/action-disconnect.request.json b/fixtures/action-disconnect.request.json
new file mode 100644
index 0000000..bde1e85
--- /dev/null
+++ b/fixtures/action-disconnect.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-disconnect-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "DISCONNECT"
+}
diff --git a/fixtures/action-pause-dispatch.request.json b/fixtures/action-pause-dispatch.request.json
new file mode 100644
index 0000000..e1feaf2
--- /dev/null
+++ b/fixtures/action-pause-dispatch.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-pause-dispatch-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "PAUSE_DISPATCH"
+}
diff --git a/fixtures/action-pause-dispatch.response.json b/fixtures/action-pause-dispatch.response.json
new file mode 100644
index 0000000..ca62fb7
--- /dev/null
+++ b/fixtures/action-pause-dispatch.response.json
@@ -0,0 +1,11 @@
+{
+ "commandId": "command-pause_dispatch-1",
+ "requestId": "action-pause-dispatch-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "PAUSE_DISPATCH",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "DESIRED_STATE_UPDATED"
+}
diff --git a/fixtures/action-reconnect.request.json b/fixtures/action-reconnect.request.json
new file mode 100644
index 0000000..5854eaa
--- /dev/null
+++ b/fixtures/action-reconnect.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-reconnect-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "RECONNECT"
+}
diff --git a/fixtures/action-refresh-inventory.request.json b/fixtures/action-refresh-inventory.request.json
new file mode 100644
index 0000000..ddd9a26
--- /dev/null
+++ b/fixtures/action-refresh-inventory.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-refresh-inventory-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "REFRESH_INVENTORY"
+}
diff --git a/fixtures/action-refresh.response.json b/fixtures/action-refresh.response.json
new file mode 100644
index 0000000..caadc7c
--- /dev/null
+++ b/fixtures/action-refresh.response.json
@@ -0,0 +1,10 @@
+{
+ "commandId": "command-refresh-1",
+ "requestId": "action-refresh-inventory-1",
+ "action": "REFRESH_INVENTORY",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "status": "PENDING"
+}
diff --git a/fixtures/action-resume-dispatch.request.json b/fixtures/action-resume-dispatch.request.json
new file mode 100644
index 0000000..993e2d8
--- /dev/null
+++ b/fixtures/action-resume-dispatch.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-resume-dispatch-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "RESUME_DISPATCH"
+}
diff --git a/fixtures/action-revoke.request.json b/fixtures/action-revoke.request.json
new file mode 100644
index 0000000..66ebe80
--- /dev/null
+++ b/fixtures/action-revoke.request.json
@@ -0,0 +1,5 @@
+{
+ "requestId": "action-revoke-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "action": "REVOKE"
+}
diff --git a/fixtures/action-revoke.response.json b/fixtures/action-revoke.response.json
new file mode 100644
index 0000000..86d7a39
--- /dev/null
+++ b/fixtures/action-revoke.response.json
@@ -0,0 +1,11 @@
+{
+ "commandId": "command-revoke-1",
+ "requestId": "action-revoke-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "REVOKE",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "CREDENTIAL_REVOKED"
+}
diff --git a/fixtures/control-already-absent.receipt.json b/fixtures/control-already-absent.receipt.json
new file mode 100644
index 0000000..279bbbc
--- /dev/null
+++ b/fixtures/control-already-absent.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-already-absent-1",
+ "outcome": "ALREADY_ABSENT",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-cancel-completed.response.json b/fixtures/control-cancel-completed.response.json
new file mode 100644
index 0000000..a8814c8
--- /dev/null
+++ b/fixtures/control-cancel-completed.response.json
@@ -0,0 +1,16 @@
+{
+ "commandId": "command-cancel-1",
+ "requestId": "action-cancel-spool-1",
+ "action": "CANCEL_SPOOL",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "jobId": "job-example-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "status": "SUCCEEDED",
+ "receipt": {
+ "receiptId": "receipt-cancel-requested-1",
+ "outcome": "CANCEL_REQUESTED",
+ "completedAt": "2026-10-31T16:00:05Z"
+ }
+}
diff --git a/fixtures/control-cancel-requested.receipt.json b/fixtures/control-cancel-requested.receipt.json
new file mode 100644
index 0000000..d47d09d
--- /dev/null
+++ b/fixtures/control-cancel-requested.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-cancel-requested-1",
+ "outcome": "CANCEL_REQUESTED",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-commands-page.response.json b/fixtures/control-commands-page.response.json
new file mode 100644
index 0000000..d2c9b4f
--- /dev/null
+++ b/fixtures/control-commands-page.response.json
@@ -0,0 +1,60 @@
+{
+ "commands": [
+ {
+ "commandId": "command-refresh-1",
+ "requestId": "action-refresh-inventory-1",
+ "action": "REFRESH_INVENTORY",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "status": "PENDING"
+ },
+ {
+ "commandId": "command-cancel-1",
+ "requestId": "action-cancel-spool-1",
+ "action": "CANCEL_SPOOL",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "jobId": "job-example-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "status": "PENDING"
+ },
+ {
+ "commandId": "command-clear_queue-1",
+ "requestId": "action-clear-queue-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "CLEAR_QUEUE",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "QUEUED_JOBS_CANCELLED",
+ "cancelledJobCount": 7
+ },
+ {
+ "commandId": "command-pause_dispatch-1",
+ "requestId": "action-pause-dispatch-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "PAUSE_DISPATCH",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "DESIRED_STATE_UPDATED"
+ },
+ {
+ "commandId": "command-revoke-1",
+ "requestId": "action-revoke-1",
+ "issuedAt": "2026-10-31T16:00:00Z",
+ "deviceId": "device-example-1",
+ "action": "REVOKE",
+ "status": "SUCCEEDED",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "resultCode": "CREDENTIAL_REVOKED"
+ }
+ ],
+ "nextCursor": null
+}
diff --git a/fixtures/control-disconnected.response.json b/fixtures/control-disconnected.response.json
new file mode 100644
index 0000000..ee874ad
--- /dev/null
+++ b/fixtures/control-disconnected.response.json
@@ -0,0 +1,11 @@
+{
+ "controlVersion": 1,
+ "serverTime": "2026-10-31T16:00:00Z",
+ "pollIntervalSeconds": 5,
+ "desiredState": {
+ "revision": 2,
+ "dispatchPaused": false,
+ "connectionState": "DISCONNECTED"
+ },
+ "commands": []
+}
diff --git a/fixtures/control-expired.receipt.json b/fixtures/control-expired.receipt.json
new file mode 100644
index 0000000..d15b97e
--- /dev/null
+++ b/fixtures/control-expired.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-expired-1",
+ "outcome": "EXPIRED",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-failed.receipt.json b/fixtures/control-failed.receipt.json
new file mode 100644
index 0000000..e251cca
--- /dev/null
+++ b/fixtures/control-failed.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-failed-1",
+ "outcome": "FAILED",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-inventory-refreshed.receipt.json b/fixtures/control-inventory-refreshed.receipt.json
new file mode 100644
index 0000000..49905b2
--- /dev/null
+++ b/fixtures/control-inventory-refreshed.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-inventory-refreshed-1",
+ "outcome": "INVENTORY_REFRESHED",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-outcome-unknown.receipt.json b/fixtures/control-outcome-unknown.receipt.json
new file mode 100644
index 0000000..0e76506
--- /dev/null
+++ b/fixtures/control-outcome-unknown.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-outcome-unknown-1",
+ "outcome": "OUTCOME_UNKNOWN",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-ownership-mismatch.receipt.json b/fixtures/control-ownership-mismatch.receipt.json
new file mode 100644
index 0000000..3408c50
--- /dev/null
+++ b/fixtures/control-ownership-mismatch.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-ownership-mismatch-1",
+ "outcome": "OWNERSHIP_MISMATCH",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/control-poll.request.json b/fixtures/control-poll.request.json
new file mode 100644
index 0000000..cf22406
--- /dev/null
+++ b/fixtures/control-poll.request.json
@@ -0,0 +1,9 @@
+{
+ "controlVersion": 1,
+ "agentVersion": "0.2.0-rc.1",
+ "capabilities": [
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ],
+ "lastAppliedRevision": 0
+}
diff --git a/fixtures/control-poll.response.json b/fixtures/control-poll.response.json
new file mode 100644
index 0000000..7ade4c8
--- /dev/null
+++ b/fixtures/control-poll.response.json
@@ -0,0 +1,27 @@
+{
+ "controlVersion": 1,
+ "serverTime": "2026-10-31T16:00:00Z",
+ "pollIntervalSeconds": 5,
+ "desiredState": {
+ "revision": 1,
+ "dispatchPaused": true,
+ "connectionState": "CONNECTED"
+ },
+ "commands": [
+ {
+ "commandId": "command-refresh-1",
+ "requestId": "action-refresh-inventory-1",
+ "action": "REFRESH_INVENTORY",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z"
+ },
+ {
+ "commandId": "command-cancel-1",
+ "requestId": "action-cancel-spool-1",
+ "action": "CANCEL_SPOOL",
+ "createdAt": "2026-10-31T16:00:00Z",
+ "expiresAt": "2026-10-31T16:15:00Z",
+ "jobId": "job-example-1"
+ }
+ ]
+}
diff --git a/fixtures/control-unsupported.receipt.json b/fixtures/control-unsupported.receipt.json
new file mode 100644
index 0000000..bc22b07
--- /dev/null
+++ b/fixtures/control-unsupported.receipt.json
@@ -0,0 +1,5 @@
+{
+ "receiptId": "receipt-unsupported-1",
+ "outcome": "UNSUPPORTED",
+ "completedAt": "2026-10-31T16:00:05Z"
+}
diff --git a/fixtures/device-control.response.json b/fixtures/device-control.response.json
new file mode 100644
index 0000000..4496aee
--- /dev/null
+++ b/fixtures/device-control.response.json
@@ -0,0 +1,20 @@
+{
+ "deviceId": "device-example-1",
+ "deviceName": "Synthetic workstation",
+ "facilityId": "facility-example",
+ "agentVersion": "0.2.0-rc.1",
+ "control": {
+ "supported": true,
+ "capabilities": [
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ],
+ "lastPollAt": "2026-10-31T16:00:00Z",
+ "lastAppliedRevision": 0,
+ "desiredState": {
+ "revision": 1,
+ "dispatchPaused": true,
+ "connectionState": "CONNECTED"
+ }
+ }
+}
diff --git a/fixtures/printing-realtime-extension.discovery.json b/fixtures/printing-realtime-extension.discovery.json
new file mode 100644
index 0000000..a08b5d9
--- /dev/null
+++ b/fixtures/printing-realtime-extension.discovery.json
@@ -0,0 +1,5 @@
+{
+ "name": "printing",
+ "version": "2.0.0",
+ "pathPrefix": "/qbit/printing"
+}
diff --git a/fixtures/printing-session-bound.response.json b/fixtures/printing-session-bound.response.json
new file mode 100644
index 0000000..164f0e7
--- /dev/null
+++ b/fixtures/printing-session-bound.response.json
@@ -0,0 +1,5 @@
+{
+ "principalId": "test-operator",
+ "tenantId": "test-tenant",
+ "deviceId": "test-device"
+}
diff --git a/fixtures/printing-session.response.json b/fixtures/printing-session.response.json
new file mode 100644
index 0000000..8f5328d
--- /dev/null
+++ b/fixtures/printing-session.response.json
@@ -0,0 +1,4 @@
+{
+ "principalId": "test-operator",
+ "tenantId": "test-tenant"
+}
diff --git a/fixtures/realtime-ack.message.json b/fixtures/realtime-ack.message.json
new file mode 100644
index 0000000..4fdc34e
--- /dev/null
+++ b/fixtures/realtime-ack.message.json
@@ -0,0 +1,7 @@
+{
+ "type": "ACK",
+ "messageId": "ack-event-spooled-1",
+ "payload": {
+ "acknowledgedMessageId": "event-spooled-1"
+ }
+}
diff --git a/fixtures/realtime-command-receipt.message.json b/fixtures/realtime-command-receipt.message.json
new file mode 100644
index 0000000..1025fa5
--- /dev/null
+++ b/fixtures/realtime-command-receipt.message.json
@@ -0,0 +1,12 @@
+{
+ "type": "COMMAND_RECEIPT",
+ "messageId": "receipt-cancel-1",
+ "payload": {
+ "commandId": "command-cancel-1",
+ "receipt": {
+ "receiptId": "receipt-cancel-1",
+ "outcome": "CANCEL_REQUESTED",
+ "completedAt": "2026-10-31T22:00:01Z"
+ }
+ }
+}
diff --git a/fixtures/realtime-command.message.json b/fixtures/realtime-command.message.json
new file mode 100644
index 0000000..34c3184
--- /dev/null
+++ b/fixtures/realtime-command.message.json
@@ -0,0 +1,12 @@
+{
+ "type": "COMMAND",
+ "messageId": "command-delivery-1",
+ "payload": {
+ "commandId": "command-cancel-1",
+ "requestId": "action-cancel-spool-1",
+ "action": "CANCEL_SPOOL",
+ "createdAt": "2026-10-31T22:00:00Z",
+ "expiresAt": "2026-10-31T22:15:00Z",
+ "jobId": "job-example-1"
+ }
+}
diff --git a/fixtures/realtime-desired-state.message.json b/fixtures/realtime-desired-state.message.json
new file mode 100644
index 0000000..1ccba57
--- /dev/null
+++ b/fixtures/realtime-desired-state.message.json
@@ -0,0 +1,9 @@
+{
+ "type": "DESIRED_STATE",
+ "messageId": "state-revision-1",
+ "payload": {
+ "revision": 1,
+ "dispatchPaused": true,
+ "connectionState": "CONNECTED"
+ }
+}
diff --git a/fixtures/realtime-error.message.json b/fixtures/realtime-error.message.json
new file mode 100644
index 0000000..943ff06
--- /dev/null
+++ b/fixtures/realtime-error.message.json
@@ -0,0 +1,9 @@
+{
+ "type": "ERROR",
+ "messageId": "event-spooled-1",
+ "payload": {
+ "code": "PERMISSION_DENIED",
+ "message": "Current printing permission is required.",
+ "retryable": false
+ }
+}
diff --git a/fixtures/realtime-heartbeat.message.json b/fixtures/realtime-heartbeat.message.json
new file mode 100644
index 0000000..a2db78e
--- /dev/null
+++ b/fixtures/realtime-heartbeat.message.json
@@ -0,0 +1,12 @@
+{
+ "type": "HEARTBEAT",
+ "messageId": "heartbeat-connection-1",
+ "payload": {
+ "agentVersion": "0.3.0-rc.1",
+ "profile": {
+ "name": "Example workstation",
+ "description": "Synthetic contract fixture",
+ "location": "Example facility"
+ }
+ }
+}
diff --git a/fixtures/realtime-hello.message.json b/fixtures/realtime-hello.message.json
new file mode 100644
index 0000000..41a84e8
--- /dev/null
+++ b/fixtures/realtime-hello.message.json
@@ -0,0 +1,25 @@
+{
+ "type": "HELLO",
+ "messageId": "hello-connection-1",
+ "payload": {
+ "agentVersion": "0.3.0-rc.1",
+ "capabilities": [
+ "realtime-v1",
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ],
+ "lastAppliedRevision": 0,
+ "lastSettingsRevision": 0,
+ "limits": {
+ "maxPdfBytes": 536870912,
+ "maxZplBytes": 104857600,
+ "maxFrameBytes": 16777216,
+ "maxQueuedFrames": 4096,
+ "maxQueuedFrameBytes": 67108864,
+ "maxConcurrentDownloads": 16,
+ "maxConcurrentPrints": 16,
+ "maxStagedBytes": 4294967296,
+ "maxCopies": 32767
+ }
+ }
+}
diff --git a/fixtures/realtime-inventory.message.json b/fixtures/realtime-inventory.message.json
new file mode 100644
index 0000000..eee4626
--- /dev/null
+++ b/fixtures/realtime-inventory.message.json
@@ -0,0 +1,18 @@
+{
+ "type": "INVENTORY",
+ "messageId": "inventory-connection-1",
+ "payload": {
+ "printers": [
+ {
+ "printerId": "laser-example",
+ "name": "Local Laser",
+ "driverName": "Example Laser Driver",
+ "portName": "USB001",
+ "status": "READY",
+ "formats": [
+ "PDF"
+ ]
+ }
+ ]
+ }
+}
diff --git a/fixtures/realtime-job-available.message.json b/fixtures/realtime-job-available.message.json
new file mode 100644
index 0000000..db57a41
--- /dev/null
+++ b/fixtures/realtime-job-available.message.json
@@ -0,0 +1,5 @@
+{
+ "type": "JOB_AVAILABLE",
+ "messageId": "jobs-wake-1",
+ "payload": {}
+}
diff --git a/fixtures/realtime-job-event-replay.message.json b/fixtures/realtime-job-event-replay.message.json
new file mode 100644
index 0000000..1133c32
--- /dev/null
+++ b/fixtures/realtime-job-event-replay.message.json
@@ -0,0 +1,13 @@
+{
+ "type": "JOB_EVENT",
+ "messageId": "event-spooled-1",
+ "payload": {
+ "jobId": "job-example-1",
+ "event": {
+ "eventId": "event-spooled-1",
+ "status": "SPOOLED",
+ "message": "Windows accepted the job; physical output is not confirmed.",
+ "spoolerJobId": "17"
+ }
+ }
+}
diff --git a/fixtures/realtime-job-event.message.json b/fixtures/realtime-job-event.message.json
new file mode 100644
index 0000000..1133c32
--- /dev/null
+++ b/fixtures/realtime-job-event.message.json
@@ -0,0 +1,13 @@
+{
+ "type": "JOB_EVENT",
+ "messageId": "event-spooled-1",
+ "payload": {
+ "jobId": "job-example-1",
+ "event": {
+ "eventId": "event-spooled-1",
+ "status": "SPOOLED",
+ "message": "Windows accepted the job; physical output is not confirmed.",
+ "spoolerJobId": "17"
+ }
+ }
+}
diff --git a/fixtures/realtime-large-pdf.response.json b/fixtures/realtime-large-pdf.response.json
new file mode 100644
index 0000000..4b2700a
--- /dev/null
+++ b/fixtures/realtime-large-pdf.response.json
@@ -0,0 +1,15 @@
+{
+ "jobId": "large-pdf-example",
+ "deviceId": "device-example",
+ "printerId": "laser-example",
+ "title": "Synthetic 100 MiB metadata example; no document included",
+ "format": "PDF",
+ "contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "contentLength": 104857600,
+ "options": {
+ "copies": 1,
+ "scale": "ACTUAL_SIZE"
+ },
+ "createdAt": "2026-10-31T22:00:00Z",
+ "status": "CLAIMED"
+}
diff --git a/fixtures/realtime-ping.message.json b/fixtures/realtime-ping.message.json
new file mode 100644
index 0000000..b589036
--- /dev/null
+++ b/fixtures/realtime-ping.message.json
@@ -0,0 +1,5 @@
+{
+ "type": "PING",
+ "messageId": "ping-connection-1",
+ "payload": {}
+}
diff --git a/fixtures/realtime-pong.message.json b/fixtures/realtime-pong.message.json
new file mode 100644
index 0000000..6d63fee
--- /dev/null
+++ b/fixtures/realtime-pong.message.json
@@ -0,0 +1,7 @@
+{
+ "type": "PONG",
+ "messageId": "pong-connection-1",
+ "payload": {
+ "serverTime": "2026-10-31T22:00:00Z"
+ }
+}
diff --git a/fixtures/realtime-settings.message.json b/fixtures/realtime-settings.message.json
new file mode 100644
index 0000000..4add5cf
--- /dev/null
+++ b/fixtures/realtime-settings.message.json
@@ -0,0 +1,29 @@
+{
+ "type": "SETTINGS",
+ "messageId": "settings-revision-2",
+ "payload": {
+ "revision": 2,
+ "maxPdfBytes": 104857600,
+ "maxZplBytes": 20971520,
+ "maxFrameBytes": 1048576,
+ "maxQueuedFrames": 64,
+ "maxQueuedFrameBytes": 4194304,
+ "maxConcurrentDownloads": 2,
+ "maxConcurrentPrints": 1,
+ "maxStagedBytes": 536870912,
+ "maxDownloadBytesPerSecond": 0,
+ "requestTimeoutMillis": 30000,
+ "downloadConnectTimeoutMillis": 30000,
+ "downloadIdleTimeoutMillis": 30000,
+ "downloadTimeoutMillis": 600000,
+ "webSocketHandshakeTimeoutMillis": 30000,
+ "heartbeatIntervalSeconds": 20,
+ "heartbeatTimeoutSeconds": 60,
+ "reconnectMinDelayMillis": 1000,
+ "reconnectMaxDelayMillis": 30000,
+ "reconnectJitterPercent": 20,
+ "ackTimeoutMillis": 15000,
+ "maxCopies": 100,
+ "nativeSubmissionTimeoutMillis": 120000
+ }
+}
diff --git a/fixtures/realtime-settings.payload.json b/fixtures/realtime-settings.payload.json
new file mode 100644
index 0000000..f402492
--- /dev/null
+++ b/fixtures/realtime-settings.payload.json
@@ -0,0 +1,25 @@
+{
+ "revision": 1,
+ "maxPdfBytes": 104857600,
+ "maxZplBytes": 20971520,
+ "maxFrameBytes": 1048576,
+ "maxQueuedFrames": 64,
+ "maxQueuedFrameBytes": 4194304,
+ "maxConcurrentDownloads": 1,
+ "maxConcurrentPrints": 1,
+ "maxStagedBytes": 536870912,
+ "maxDownloadBytesPerSecond": 0,
+ "requestTimeoutMillis": 30000,
+ "downloadConnectTimeoutMillis": 30000,
+ "downloadIdleTimeoutMillis": 30000,
+ "downloadTimeoutMillis": 600000,
+ "webSocketHandshakeTimeoutMillis": 30000,
+ "heartbeatIntervalSeconds": 20,
+ "heartbeatTimeoutSeconds": 60,
+ "reconnectMinDelayMillis": 1000,
+ "reconnectMaxDelayMillis": 30000,
+ "reconnectJitterPercent": 20,
+ "ackTimeoutMillis": 15000,
+ "maxCopies": 100,
+ "nativeSubmissionTimeoutMillis": 120000
+}
diff --git a/fixtures/realtime-state-applied.message.json b/fixtures/realtime-state-applied.message.json
new file mode 100644
index 0000000..d41c92f
--- /dev/null
+++ b/fixtures/realtime-state-applied.message.json
@@ -0,0 +1,8 @@
+{
+ "type": "STATE_APPLIED",
+ "messageId": "applied-connection-1",
+ "payload": {
+ "lastAppliedRevision": 0,
+ "lastSettingsRevision": 1
+ }
+}
diff --git a/fixtures/realtime-welcome.message.json b/fixtures/realtime-welcome.message.json
new file mode 100644
index 0000000..863b531
--- /dev/null
+++ b/fixtures/realtime-welcome.message.json
@@ -0,0 +1,38 @@
+{
+ "type": "WELCOME",
+ "messageId": "welcome-connection-1",
+ "payload": {
+ "connectionId": "connection-example-1",
+ "serverTime": "2026-10-31T22:00:00Z",
+ "desiredState": {
+ "revision": 0,
+ "dispatchPaused": false,
+ "connectionState": "CONNECTED"
+ },
+ "settings": {
+ "revision": 1,
+ "maxPdfBytes": 104857600,
+ "maxZplBytes": 20971520,
+ "maxFrameBytes": 1048576,
+ "maxQueuedFrames": 64,
+ "maxQueuedFrameBytes": 4194304,
+ "maxConcurrentDownloads": 1,
+ "maxConcurrentPrints": 1,
+ "maxStagedBytes": 536870912,
+ "maxDownloadBytesPerSecond": 0,
+ "requestTimeoutMillis": 30000,
+ "downloadConnectTimeoutMillis": 30000,
+ "downloadIdleTimeoutMillis": 30000,
+ "downloadTimeoutMillis": 600000,
+ "webSocketHandshakeTimeoutMillis": 30000,
+ "heartbeatIntervalSeconds": 20,
+ "heartbeatTimeoutSeconds": 60,
+ "reconnectMinDelayMillis": 1000,
+ "reconnectMaxDelayMillis": 30000,
+ "reconnectJitterPercent": 20,
+ "ackTimeoutMillis": 15000,
+ "maxCopies": 100,
+ "nativeSubmissionTimeoutMillis": 120000
+ }
+ }
+}
diff --git a/fixtures/version-controls.response.json b/fixtures/version-controls.response.json
new file mode 100644
index 0000000..ae3b8a3
--- /dev/null
+++ b/fixtures/version-controls.response.json
@@ -0,0 +1,6 @@
+{
+ "protocolVersion": 1,
+ "capabilities": [
+ "device-control-v1"
+ ]
+}
diff --git a/fixtures/version-legacy.response.json b/fixtures/version-legacy.response.json
new file mode 100644
index 0000000..91bb30e
--- /dev/null
+++ b/fixtures/version-legacy.response.json
@@ -0,0 +1,3 @@
+{
+ "protocolVersion": 1
+}
diff --git a/package-lock.json b/package-lock.json
index 5f9c189..740fc4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@qrun-io/qqq-print-protocol",
- "version": "0.1.4-rc.1",
+ "version": "0.3.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qqq-print-protocol",
- "version": "0.1.4-rc.1",
+ "version": "0.3.0-rc.1",
"license": "Apache-2.0",
"devDependencies": {
"ajv": "8.20.0",
diff --git a/package.json b/package.json
index 78208f9..3eae908 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,13 @@
{
"name": "@qrun-io/qqq-print-protocol",
- "version": "0.1.4-rc.1",
+ "version": "0.3.0-rc.1",
"private": true,
"description": "Versioned wire contract and compatibility fixtures for QQQ cloud printing.",
"license": "Apache-2.0",
"type": "module",
- "engines": { "node": ">=24" },
+ "engines": {
+ "node": ">=24"
+ },
"scripts": {
"check": "node tools/verify-schema.mjs && node tools/verify-fixtures.mjs",
"bundle": "node tools/bundle.mjs"
diff --git a/schemas/protocol.schema.json b/schemas/protocol.schema.json
index c622b1f..2b1e708 100644
--- a/schemas/protocol.schema.json
+++ b/schemas/protocol.schema.json
@@ -4,6 +4,27 @@
"title": "QQQ Print Protocol v1",
"description": "Select the DTO with #/$defs/Name.",
"$defs": {
+ "PrintingRealtimeExtensionDiscovery": {
+ "description": "The existing core extensions entry required by realtime clients; this does not redefine the core discovery document or its API version.",
+ "type": "object",
+ "required": ["name", "version", "pathPrefix"],
+ "properties": {
+ "name": { "type": "string", "const": "printing" },
+ "version": { "type": "string", "pattern": "^2\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", "maxLength": 100 },
+ "pathPrefix": { "type": "string", "const": "/qbit/printing" }
+ },
+ "additionalProperties": true
+ },
+ "PrintingSessionIdentity": {
+ "type": "object",
+ "required": ["principalId", "tenantId"],
+ "properties": {
+ "principalId": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "tenantId": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "deviceId": { "$ref": "#/$defs/Id" }
+ },
+ "additionalProperties": true
+ },
"DeviceTestPrintRequest": {
"type": "object",
"required": [
@@ -26,13 +47,40 @@
}
},
"anyOf": [
- { "type": "object", "properties": { "format": { "const": "PDF" } } },
- { "type": "object", "properties": { "format": { "const": "ZPL" }, "testProfile": { "enum": ["LABEL_4X6_203", "LABEL_4X6_300", "LABEL_4X6_600"] } } }
+ {
+ "type": "object",
+ "properties": {
+ "format": {
+ "const": "PDF"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "format": {
+ "const": "ZPL"
+ },
+ "testProfile": {
+ "enum": [
+ "LABEL_4X6_203",
+ "LABEL_4X6_300",
+ "LABEL_4X6_600"
+ ]
+ }
+ }
+ }
]
},
"TestPrintProfile": {
"description": "Optional test-document media and label resolution. Omitted defaults: PDF LETTER, ZPL LABEL_4X6_203. This does not change a Windows queue's configured stock.",
- "enum": ["LETTER", "A4", "LABEL_4X6_203", "LABEL_4X6_300", "LABEL_4X6_600"]
+ "enum": [
+ "LETTER",
+ "A4",
+ "LABEL_4X6_203",
+ "LABEL_4X6_300",
+ "LABEL_4X6_600"
+ ]
},
"DeviceRetryRequest": {
"type": "object",
@@ -407,7 +455,7 @@
"copies": {
"type": "integer",
"minimum": 1,
- "maximum": 100,
+ "maximum": 32767,
"default": 1
},
"scale": {
@@ -507,7 +555,8 @@
"contentLength": {
"type": "integer",
"minimum": 1,
- "maximum": 20971520
+ "maximum": 2147483647,
+ "description": "Syntactic ceiling; the discovered transport's effective format policy and declared client capability impose the actual limit."
},
"options": {
"$ref": "#/$defs/PrintOptions"
@@ -618,6 +667,9 @@
},
"profile": {
"$ref": "#/$defs/DeviceProfile"
+ },
+ "control": {
+ "$ref": "#/$defs/DeviceControlStatus"
}
},
"required": [
@@ -655,8 +707,13 @@
},
"nextCursor": {
"anyOf": [
- { "type": "string", "maxLength": 12000 },
- { "type": "null" }
+ {
+ "type": "string",
+ "maxLength": 12000
+ },
+ {
+ "type": "null"
+ }
]
}
},
@@ -749,6 +806,1223 @@
},
"additionalProperties": true,
"description": "Client-authoritative labels. Null or omitted preserves metadata; empty text clears the field. Empty name falls back to the technical computer name."
+ },
+ "ControlTimestamp": {
+ "type": "string",
+ "format": "date-time",
+ "maxLength": 40,
+ "pattern": "Z$",
+ "description": "RFC 3339 UTC timestamp, ending in uppercase Z."
+ },
+ "ControlCapability": {
+ "enum": [
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ]
+ },
+ "ProtocolVersionResponse": {
+ "type": "object",
+ "required": [
+ "protocolVersion"
+ ],
+ "properties": {
+ "protocolVersion": {
+ "const": 1
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "maxItems": 32,
+ "uniqueItems": true
+ }
+ },
+ "additionalProperties": true
+ },
+ "DeviceDesiredState": {
+ "type": "object",
+ "required": [
+ "revision",
+ "dispatchPaused",
+ "connectionState"
+ ],
+ "properties": {
+ "revision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "dispatchPaused": {
+ "type": "boolean"
+ },
+ "connectionState": {
+ "enum": [
+ "CONNECTED",
+ "DISCONNECTED"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "DeviceControlStatus": {
+ "type": "object",
+ "required": [
+ "supported",
+ "capabilities",
+ "lastAppliedRevision",
+ "desiredState"
+ ],
+ "properties": {
+ "supported": {
+ "type": "boolean"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/ControlCapability"
+ },
+ "maxItems": 2,
+ "uniqueItems": true
+ },
+ "lastPollAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "lastAppliedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "desiredState": {
+ "$ref": "#/$defs/DeviceDesiredState"
+ }
+ },
+ "additionalProperties": true
+ },
+ "DeviceControlPollRequest": {
+ "type": "object",
+ "required": [
+ "controlVersion",
+ "agentVersion",
+ "capabilities",
+ "lastAppliedRevision"
+ ],
+ "properties": {
+ "controlVersion": {
+ "const": 1
+ },
+ "agentVersion": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/ControlCapability"
+ },
+ "maxItems": 2,
+ "uniqueItems": true
+ },
+ "lastAppliedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ }
+ },
+ "additionalProperties": false
+ },
+ "DeviceCommand": {
+ "type": "object",
+ "required": [
+ "commandId",
+ "requestId",
+ "action",
+ "createdAt",
+ "expiresAt"
+ ],
+ "properties": {
+ "commandId": {
+ "$ref": "#/$defs/Id"
+ },
+ "requestId": {
+ "$ref": "#/$defs/Id"
+ },
+ "action": {
+ "enum": [
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ]
+ },
+ "createdAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "expiresAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ }
+ },
+ "additionalProperties": true,
+ "anyOf": [
+ {
+ "type": "object",
+ "required": [
+ "jobId"
+ ],
+ "properties": {
+ "action": {
+ "const": "CANCEL_SPOOL"
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "enum": [
+ "REFRESH_INVENTORY"
+ ]
+ },
+ "jobId": false
+ }
+ }
+ ]
+ },
+ "DeviceControlPollResponse": {
+ "type": "object",
+ "required": [
+ "controlVersion",
+ "serverTime",
+ "pollIntervalSeconds",
+ "desiredState",
+ "commands"
+ ],
+ "properties": {
+ "controlVersion": {
+ "const": 1
+ },
+ "serverTime": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "pollIntervalSeconds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 300
+ },
+ "desiredState": {
+ "$ref": "#/$defs/DeviceDesiredState"
+ },
+ "commands": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/DeviceCommand"
+ },
+ "maxItems": 20
+ }
+ },
+ "additionalProperties": true
+ },
+ "DeviceCommandReceipt": {
+ "type": "object",
+ "required": [
+ "receiptId",
+ "outcome",
+ "completedAt"
+ ],
+ "properties": {
+ "receiptId": {
+ "$ref": "#/$defs/Id"
+ },
+ "outcome": {
+ "enum": [
+ "INVENTORY_REFRESHED",
+ "CANCEL_REQUESTED",
+ "ALREADY_ABSENT",
+ "OWNERSHIP_MISMATCH",
+ "UNSUPPORTED",
+ "FAILED",
+ "EXPIRED",
+ "OUTCOME_UNKNOWN"
+ ]
+ },
+ "completedAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "message": {
+ "type": "string",
+ "maxLength": 1024
+ }
+ },
+ "additionalProperties": false
+ },
+ "AdminDeviceActionRequest": {
+ "type": "object",
+ "required": [
+ "requestId",
+ "issuedAt",
+ "action"
+ ],
+ "properties": {
+ "requestId": {
+ "$ref": "#/$defs/Id"
+ },
+ "issuedAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "action": {
+ "enum": [
+ "PAUSE_DISPATCH",
+ "RESUME_DISPATCH",
+ "DISCONNECT",
+ "RECONNECT",
+ "REVOKE",
+ "REFRESH_INVENTORY",
+ "CLEAR_QUEUE",
+ "CANCEL_SPOOL"
+ ]
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ }
+ },
+ "additionalProperties": false,
+ "anyOf": [
+ {
+ "type": "object",
+ "required": [
+ "jobId"
+ ],
+ "properties": {
+ "action": {
+ "const": "CANCEL_SPOOL"
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "enum": [
+ "PAUSE_DISPATCH",
+ "RESUME_DISPATCH",
+ "DISCONNECT",
+ "RECONNECT",
+ "REVOKE",
+ "REFRESH_INVENTORY",
+ "CLEAR_QUEUE"
+ ]
+ },
+ "jobId": false
+ }
+ }
+ ]
+ },
+ "DeviceActionRecord": {
+ "type": "object",
+ "required": [
+ "commandId",
+ "requestId",
+ "issuedAt",
+ "deviceId",
+ "action",
+ "status",
+ "createdAt",
+ "expiresAt"
+ ],
+ "properties": {
+ "commandId": {
+ "$ref": "#/$defs/Id"
+ },
+ "requestId": {
+ "$ref": "#/$defs/Id"
+ },
+ "issuedAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "deviceId": {
+ "$ref": "#/$defs/Id"
+ },
+ "action": {
+ "enum": [
+ "PAUSE_DISPATCH",
+ "RESUME_DISPATCH",
+ "DISCONNECT",
+ "RECONNECT",
+ "REVOKE",
+ "REFRESH_INVENTORY",
+ "CLEAR_QUEUE",
+ "CANCEL_SPOOL"
+ ]
+ },
+ "status": {
+ "enum": [
+ "PENDING",
+ "SUCCEEDED",
+ "FAILED",
+ "EXPIRED",
+ "OUTCOME_UNKNOWN"
+ ]
+ },
+ "createdAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "expiresAt": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ },
+ "resultCode": {
+ "enum": [
+ "DESIRED_STATE_UPDATED",
+ "CREDENTIAL_REVOKED",
+ "QUEUED_JOBS_CANCELLED"
+ ]
+ },
+ "cancelledJobCount": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "receipt": {
+ "$ref": "#/$defs/DeviceCommandReceipt"
+ }
+ },
+ "additionalProperties": true,
+ "anyOf": [
+ {
+ "type": "object",
+ "required": [
+ "jobId"
+ ],
+ "properties": {
+ "action": {
+ "const": "CANCEL_SPOOL"
+ },
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "action": {
+ "enum": [
+ "PAUSE_DISPATCH",
+ "RESUME_DISPATCH",
+ "DISCONNECT",
+ "RECONNECT",
+ "REVOKE",
+ "REFRESH_INVENTORY",
+ "CLEAR_QUEUE"
+ ]
+ },
+ "jobId": false
+ }
+ }
+ ]
+ },
+ "DeviceActionList": {
+ "type": "object",
+ "required": [
+ "commands",
+ "nextCursor"
+ ],
+ "properties": {
+ "commands": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/DeviceActionRecord"
+ },
+ "maxItems": 200
+ },
+ "nextCursor": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 12000
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "RealtimeSettingsMessage": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "SETTINGS"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeSettings"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeSettings": {
+ "description": "Authoritative operational settings. Cross-field and declared-client-ceiling validation in REALTIME.md is also required.",
+ "type": "object",
+ "required": [
+ "revision",
+ "maxCopies",
+ "maxPdfBytes",
+ "maxZplBytes",
+ "maxFrameBytes",
+ "maxQueuedFrames",
+ "maxQueuedFrameBytes",
+ "maxConcurrentDownloads",
+ "maxConcurrentPrints",
+ "maxStagedBytes",
+ "maxDownloadBytesPerSecond",
+ "requestTimeoutMillis",
+ "downloadConnectTimeoutMillis",
+ "downloadIdleTimeoutMillis",
+ "downloadTimeoutMillis",
+ "webSocketHandshakeTimeoutMillis",
+ "heartbeatIntervalSeconds",
+ "heartbeatTimeoutSeconds",
+ "reconnectMinDelayMillis",
+ "reconnectMaxDelayMillis",
+ "reconnectJitterPercent",
+ "ackTimeoutMillis",
+ "nativeSubmissionTimeoutMillis"
+ ],
+ "properties": {
+ "revision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "maxCopies": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32767,
+ "default": 100
+ },
+ "maxPdfBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647,
+ "default": 104857600
+ },
+ "maxZplBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2147483647,
+ "default": 20971520
+ },
+ "maxFrameBytes": {
+ "type": "integer",
+ "minimum": 8192,
+ "maximum": 16777216,
+ "default": 1048576
+ },
+ "maxQueuedFrames": {
+ "type": "integer",
+ "minimum": 8,
+ "maximum": 4096,
+ "default": 64
+ },
+ "maxQueuedFrameBytes": {
+ "type": "integer",
+ "minimum": 8192,
+ "maximum": 67108864,
+ "default": 4194304
+ },
+ "maxConcurrentDownloads": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 16,
+ "default": 1
+ },
+ "maxConcurrentPrints": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 16,
+ "default": 1
+ },
+ "maxStagedBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991,
+ "default": 536870912
+ },
+ "maxDownloadBytesPerSecond": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 2147483647,
+ "default": 0,
+ "description": "Aggregate content-download bandwidth; zero explicitly means unlimited."
+ },
+ "requestTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "default": 30000
+ },
+ "downloadConnectTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "default": 30000
+ },
+ "downloadIdleTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 3600000,
+ "default": 30000
+ },
+ "downloadTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 86400000,
+ "default": 600000
+ },
+ "webSocketHandshakeTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "default": 30000
+ },
+ "heartbeatIntervalSeconds": {
+ "type": "integer",
+ "minimum": 5,
+ "maximum": 300,
+ "default": 20
+ },
+ "heartbeatTimeoutSeconds": {
+ "type": "integer",
+ "minimum": 10,
+ "maximum": 900,
+ "default": 60
+ },
+ "reconnectMinDelayMillis": {
+ "type": "integer",
+ "minimum": 100,
+ "maximum": 60000,
+ "default": 1000
+ },
+ "reconnectMaxDelayMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "default": 30000
+ },
+ "reconnectJitterPercent": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 50,
+ "default": 20
+ },
+ "ackTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 300000,
+ "default": 15000
+ },
+ "nativeSubmissionTimeoutMillis": {
+ "type": "integer",
+ "minimum": 1000,
+ "maximum": 86400000,
+ "default": 120000,
+ "description": "Deadline for the isolated native printer submission; timeout cannot establish that no output was submitted."
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeClientLimits": {
+ "type": "object",
+ "required": [
+ "maxCopies",
+ "maxPdfBytes",
+ "maxZplBytes",
+ "maxFrameBytes",
+ "maxQueuedFrames",
+ "maxQueuedFrameBytes",
+ "maxConcurrentDownloads",
+ "maxConcurrentPrints",
+ "maxStagedBytes"
+ ],
+ "properties": {
+ "maxCopies": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32767
+ },
+ "maxPdfBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxZplBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxFrameBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxQueuedFrames": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxQueuedFrameBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxConcurrentDownloads": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxConcurrentPrints": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "maxStagedBytes": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeEmpty": {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "additionalProperties": false
+ },
+ "RealtimeHelloPayload": {
+ "type": "object",
+ "required": [
+ "agentVersion",
+ "capabilities",
+ "lastAppliedRevision",
+ "lastSettingsRevision",
+ "limits"
+ ],
+ "properties": {
+ "agentVersion": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "enum": [
+ "realtime-v1",
+ "REFRESH_INVENTORY",
+ "CANCEL_SPOOL"
+ ]
+ },
+ "minItems": 1,
+ "maxItems": 3,
+ "uniqueItems": true,
+ "contains": {
+ "const": "realtime-v1"
+ }
+ },
+ "lastAppliedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "lastSettingsRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "limits": {
+ "$ref": "#/$defs/RealtimeClientLimits"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeWelcomePayload": {
+ "type": "object",
+ "required": [
+ "connectionId",
+ "serverTime",
+ "desiredState",
+ "settings"
+ ],
+ "properties": {
+ "connectionId": {
+ "$ref": "#/$defs/Id"
+ },
+ "serverTime": {
+ "$ref": "#/$defs/ControlTimestamp"
+ },
+ "desiredState": {
+ "$ref": "#/$defs/DeviceDesiredState"
+ },
+ "settings": {
+ "$ref": "#/$defs/RealtimeSettings"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeStateAppliedPayload": {
+ "type": "object",
+ "required": [
+ "lastAppliedRevision",
+ "lastSettingsRevision"
+ ],
+ "properties": {
+ "lastAppliedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "lastSettingsRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeJobEventPayload": {
+ "type": "object",
+ "required": [
+ "jobId",
+ "event"
+ ],
+ "properties": {
+ "jobId": {
+ "$ref": "#/$defs/Id"
+ },
+ "event": {
+ "$ref": "#/$defs/JobEvent"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeCommandReceiptPayload": {
+ "type": "object",
+ "required": [
+ "commandId",
+ "receipt"
+ ],
+ "properties": {
+ "commandId": {
+ "$ref": "#/$defs/Id"
+ },
+ "receipt": {
+ "$ref": "#/$defs/DeviceCommandReceipt"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeAckPayload": {
+ "type": "object",
+ "required": [
+ "acknowledgedMessageId"
+ ],
+ "properties": {
+ "acknowledgedMessageId": {
+ "$ref": "#/$defs/Id"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeErrorPayload": {
+ "type": "object",
+ "required": [
+ "code",
+ "message",
+ "retryable"
+ ],
+ "properties": {
+ "code": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "message": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048
+ },
+ "retryable": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimePongPayload": {
+ "type": "object",
+ "required": [
+ "serverTime"
+ ],
+ "properties": {
+ "serverTime": {
+ "$ref": "#/$defs/ControlTimestamp"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeHello": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "HELLO"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeHelloPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeStateApplied": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "STATE_APPLIED"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeStateAppliedPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeJobEvent": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "JOB_EVENT"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeJobEventPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeCommandReceipt": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "COMMAND_RECEIPT"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeCommandReceiptPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeHeartbeat": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "HEARTBEAT"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/HeartbeatRequest"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeInventory": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "INVENTORY"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/PrinterInventory"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimePing": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "PING"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeEmpty"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeWelcome": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "WELCOME"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeWelcomePayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeDesiredState": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "DESIRED_STATE"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/DeviceDesiredState"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeJobAvailable": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "JOB_AVAILABLE"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeEmpty"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeCommand": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "COMMAND"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/DeviceCommand"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeAck": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "ACK"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeAckPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeError": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "ERROR"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimeErrorPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimePong": {
+ "type": "object",
+ "required": [
+ "type",
+ "messageId",
+ "payload"
+ ],
+ "properties": {
+ "type": {
+ "const": "PONG"
+ },
+ "messageId": {
+ "$ref": "#/$defs/Id"
+ },
+ "payload": {
+ "$ref": "#/$defs/RealtimePongPayload"
+ }
+ },
+ "additionalProperties": false
+ },
+ "RealtimeClientMessage": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/RealtimeHello"
+ },
+ {
+ "$ref": "#/$defs/RealtimeStateApplied"
+ },
+ {
+ "$ref": "#/$defs/RealtimeJobEvent"
+ },
+ {
+ "$ref": "#/$defs/RealtimeCommandReceipt"
+ },
+ {
+ "$ref": "#/$defs/RealtimeHeartbeat"
+ },
+ {
+ "$ref": "#/$defs/RealtimeInventory"
+ },
+ {
+ "$ref": "#/$defs/RealtimePing"
+ }
+ ]
+ },
+ "RealtimeServerMessage": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/RealtimeWelcome"
+ },
+ {
+ "$ref": "#/$defs/RealtimeDesiredState"
+ },
+ {
+ "$ref": "#/$defs/RealtimeJobAvailable"
+ },
+ {
+ "$ref": "#/$defs/RealtimeCommand"
+ },
+ {
+ "$ref": "#/$defs/RealtimeSettingsMessage"
+ },
+ {
+ "$ref": "#/$defs/RealtimeAck"
+ },
+ {
+ "$ref": "#/$defs/RealtimeError"
+ },
+ {
+ "$ref": "#/$defs/RealtimePong"
+ }
+ ]
}
}
}
diff --git a/tools/bundle.mjs b/tools/bundle.mjs
index f15eb0c..b2e7c8a 100644
--- a/tools/bundle.mjs
+++ b/tools/bundle.mjs
@@ -1,3 +1,6 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -13,7 +16,7 @@ if(process.env.GITHUB_REF_TYPE === 'tag')
}
mkdirSync(new URL('../dist/', import.meta.url), { recursive: true });
const name = `qqq-print-protocol-${manifest.version}.tar.gz`;
-const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md'];
+const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md', 'docs/DEVICE-CONTROLS.md', 'docs/QQQ-AUTHENTICATION.md', 'docs/REALTIME.md'];
const result = spawnSync('tar', ['-czf', `dist/${name}`, ...files], { cwd: root, stdio: 'inherit', shell: false });
if(result.error) throw result.error;
assert.equal(result.status, 0, 'Archive creation failed.');
diff --git a/tools/create-content-fixtures.mjs b/tools/create-content-fixtures.mjs
index 324e677..a96558c 100644
--- a/tools/create-content-fixtures.mjs
+++ b/tools/create-content-fixtures.mjs
@@ -1,3 +1,6 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
diff --git a/tools/fixture-types.mjs b/tools/fixture-types.mjs
index 8ac5208..987029b 100644
--- a/tools/fixture-types.mjs
+++ b/tools/fixture-types.mjs
@@ -1,5 +1,38 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
// Shared correspondence between wire examples and schema definitions.
export const fixtureTypes = {
+ 'printing-realtime-extension.discovery.json': 'PrintingRealtimeExtensionDiscovery',
+ 'version-legacy.response.json': 'ProtocolVersionResponse',
+ 'version-controls.response.json': 'ProtocolVersionResponse',
+ 'control-poll.request.json': 'DeviceControlPollRequest',
+ 'control-poll.response.json': 'DeviceControlPollResponse',
+ 'control-disconnected.response.json': 'DeviceControlPollResponse',
+ 'device-control.response.json': 'Device',
+ 'action-pause-dispatch.request.json': 'AdminDeviceActionRequest',
+ 'action-resume-dispatch.request.json': 'AdminDeviceActionRequest',
+ 'action-disconnect.request.json': 'AdminDeviceActionRequest',
+ 'action-reconnect.request.json': 'AdminDeviceActionRequest',
+ 'action-revoke.request.json': 'AdminDeviceActionRequest',
+ 'action-refresh-inventory.request.json': 'AdminDeviceActionRequest',
+ 'action-clear-queue.request.json': 'AdminDeviceActionRequest',
+ 'action-cancel-spool.request.json': 'AdminDeviceActionRequest',
+ 'action-refresh.response.json': 'DeviceActionRecord',
+ 'action-cancel.response.json': 'DeviceActionRecord',
+ 'action-clear-queue.response.json': 'DeviceActionRecord',
+ 'action-pause-dispatch.response.json': 'DeviceActionRecord',
+ 'action-revoke.response.json': 'DeviceActionRecord',
+ 'control-inventory-refreshed.receipt.json': 'DeviceCommandReceipt',
+ 'control-cancel-requested.receipt.json': 'DeviceCommandReceipt',
+ 'control-already-absent.receipt.json': 'DeviceCommandReceipt',
+ 'control-ownership-mismatch.receipt.json': 'DeviceCommandReceipt',
+ 'control-unsupported.receipt.json': 'DeviceCommandReceipt',
+ 'control-failed.receipt.json': 'DeviceCommandReceipt',
+ 'control-expired.receipt.json': 'DeviceCommandReceipt',
+ 'control-outcome-unknown.receipt.json': 'DeviceCommandReceipt',
+ 'control-commands-page.response.json': 'DeviceActionList',
+ 'control-cancel-completed.response.json': 'DeviceActionRecord',
'heartbeat-profile.request.json': 'HeartbeatRequest',
'printers-profile.request.json': 'PrinterInventory',
'device-test.request.json': 'DeviceTestPrintRequest',
@@ -22,5 +55,25 @@ export const fixtureTypes = {
'submitting.event.json': 'JobEvent',
'spooled.event.json': 'JobEvent',
'ack.response.json': 'Acknowledgment',
- 'error.response.json': 'Error'
+ 'error.response.json': 'Error',
+ 'printing-session.response.json': 'PrintingSessionIdentity',
+ 'printing-session-bound.response.json': 'PrintingSessionIdentity',
+ 'realtime-settings.payload.json': 'RealtimeSettings',
+ 'realtime-hello.message.json': 'RealtimeHello',
+ 'realtime-welcome.message.json': 'RealtimeWelcome',
+ 'realtime-state-applied.message.json': 'RealtimeStateApplied',
+ 'realtime-desired-state.message.json': 'RealtimeDesiredState',
+ 'realtime-job-available.message.json': 'RealtimeJobAvailable',
+ 'realtime-command.message.json': 'RealtimeCommand',
+ 'realtime-command-receipt.message.json': 'RealtimeCommandReceipt',
+ 'realtime-job-event.message.json': 'RealtimeJobEvent',
+ 'realtime-job-event-replay.message.json': 'RealtimeJobEvent',
+ 'realtime-settings.message.json': 'RealtimeSettingsMessage',
+ 'realtime-ack.message.json': 'RealtimeAck',
+ 'realtime-error.message.json': 'RealtimeError',
+ 'realtime-heartbeat.message.json': 'RealtimeHeartbeat',
+ 'realtime-inventory.message.json': 'RealtimeInventory',
+ 'realtime-ping.message.json': 'RealtimePing',
+ 'realtime-pong.message.json': 'RealtimePong',
+ 'realtime-large-pdf.response.json': 'PrintJob'
};
diff --git a/tools/verify-fixtures.mjs b/tools/verify-fixtures.mjs
index 2e77f8f..e9b59a9 100644
--- a/tools/verify-fixtures.mjs
+++ b/tools/verify-fixtures.mjs
@@ -1,3 +1,6 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -9,6 +12,11 @@ const root = new URL('../fixtures/', import.meta.url);
// Validate the schema keywords used by this contract without installing dependencies.
function validate(value, rule, path)
{
+ if(typeof rule === 'boolean')
+ {
+ assert(rule, `${path}: forbidden property`);
+ return;
+ }
if(rule.$ref)
{
return validate(value, schema.$defs[rule.$ref.split('/').at(-1)], path);
@@ -28,6 +36,10 @@ function validate(value, rule, path)
{
assert(value !== null && typeof value === 'object' && !Array.isArray(value), `${path}: expected object`);
for(const name of rule.required ?? []) assert(name in value, `${path}.${name}: required`);
+ if(rule.additionalProperties === false)
+ {
+ for(const name of Object.keys(value)) assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`);
+ }
for(const [name, child] of Object.entries(rule.properties ?? {}))
{
if(name in value) validate(value[name], child, `${path}.${name}`);
@@ -43,6 +55,14 @@ function validate(value, rule, path)
if(rule.minItems !== undefined) assert(value.length >= rule.minItems, path);
if(rule.maxItems !== undefined) assert(value.length <= rule.maxItems, path);
if(rule.uniqueItems) assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path);
+ if(rule.contains)
+ {
+ assert(value.some(item =>
+ {
+ try { validate(item, rule.contains, path); return true; }
+ catch { return false; }
+ }), `${path}: required array member missing`);
+ }
value.forEach((item, index) => validate(item, rule.items, `${path}[${index}]`));
}
if(rule.type === 'string')
@@ -91,4 +111,12 @@ assert.throws(() => validate({ ...brandedTest, testProfile: 'UNKNOWN' }, schema.
assert.throws(() => validate({ ...brandedTest, testProfile: null }, schema.$defs.DeviceTestPrintRequest, 'nullTestProfile'));
assert.throws(() => validate({ format: 'PDF' }, schema.$defs.DeviceTestPrintRequest, 'missingTestIdentity'));
validate({ ...brandedTest, format: 'PDF', testProfile: 'A4' }, schema.$defs.DeviceTestPrintRequest, 'a4TestPage');
+const clear = fixtures['action-clear-queue.request.json'];
+assert.throws(() => validate({ ...clear, issuedAt: undefined }, schema.$defs.AdminDeviceActionRequest, 'missingIssuedAt'));
+assert.throws(() => validate({ ...clear, jobId: 'job-example-1' }, schema.$defs.AdminDeviceActionRequest, 'clearCannotTargetSpool'));
+assert.throws(() => validate({ ...clear, action: 'RESTART_SERVICE' }, schema.$defs.AdminDeviceActionRequest, 'arbitraryServiceControl'));
+assert.throws(() => validate({ ...clear, command: 'shell' }, schema.$defs.AdminDeviceActionRequest, 'unknownCommandProperty'));
+assert.throws(() => validate({ ...clear, action: 'CANCEL_SPOOL' }, schema.$defs.AdminDeviceActionRequest, 'cancelRequiresJobIdentity'));
+assert.throws(() => validate({ ...fixtures['control-poll.request.json'], capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] }, schema.$defs.DeviceControlPollRequest, 'duplicateCapability'));
+assert.throws(() => validate({ ...fixtures['control-cancel-requested.receipt.json'], outcome: 'PRINT_RECALLED' }, schema.$defs.DeviceCommandReceipt, 'unsupportedPaperClaim'));
console.log(`Validated ${Object.keys(fixtures).length} protocol fixtures, content integrity, and invalid-input checks.`);
diff --git a/tools/verify-realtime.mjs b/tools/verify-realtime.mjs
new file mode 100644
index 0000000..52dfb12
--- /dev/null
+++ b/tools/verify-realtime.mjs
@@ -0,0 +1,113 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import Ajv2020 from 'ajv/dist/2020.js';
+import addFormats from 'ajv-formats';
+
+const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
+const ajv = new Ajv2020({ strict: true, allErrors: true });
+addFormats(ajv);
+ajv.addSchema(schema);
+const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
+const valid = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
+const settings = fixture('realtime-settings.payload.json');
+const hello = fixture('realtime-hello.message.json');
+const extension = fixture('printing-realtime-extension.discovery.json');
+assert(valid('PrintingRealtimeExtensionDiscovery', extension), ajv.errorsText());
+assert.equal(extension.version, '2.0.0');
+assert(valid('PrintingRealtimeExtensionDiscovery', { ...extension, version: '2.7.3' }));
+for(const version of ['1.0.0', '1.1.0', '3.0.0', '2.0', '2.01.0', '2.0.0-rc.1', null])
+{
+ assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, version }), 'Only the compatible stable native major is accepted.');
+}
+assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, name: 'another-extension' }));
+assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, pathPrefix: '/printing/v1' }));
+
+/*******************************************************************************
+ ** Cross-field policy relationships require semantic checks beyond JSON Schema.
+ *******************************************************************************/
+function assertPolicy(policy, limits)
+{
+ assert(valid('RealtimeSettings', policy), ajv.errorsText());
+ assert(valid('RealtimeClientLimits', limits), ajv.errorsText());
+ assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, 'Frame queue must hold one permitted message.');
+ assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), 'Staging must admit one permitted document.');
+ assert(policy.downloadTimeoutMillis >= policy.downloadConnectTimeoutMillis);
+ assert(policy.downloadTimeoutMillis >= policy.downloadIdleTimeoutMillis);
+ assert(policy.heartbeatTimeoutSeconds >= 2 * policy.heartbeatIntervalSeconds);
+ assert(policy.reconnectMaxDelayMillis >= policy.reconnectMinDelayMillis);
+ for(const name of schema.$defs.RealtimeClientLimits.required)
+ {
+ assert(policy[name] <= limits[name], `${name}: policy exceeds declared client capability`);
+ }
+}
+
+assertPolicy(settings, hello.payload.limits);
+assert.equal(settings.maxConcurrentDownloads, 1, 'Initial deployment defaults to one configurable download lane.');
+assert.equal(settings.maxCopies, 100);
+assert.equal(settings.nativeSubmissionTimeoutMillis, 120000);
+for(const [name, rule] of Object.entries(schema.$defs.RealtimeSettings.properties))
+{
+ if(rule.default !== undefined) assert.equal(settings[name], rule.default, `${name}: fixture/schema default mismatch`);
+ const missing = { ...settings };
+ delete missing[name];
+ assert(!valid('RealtimeSettings', missing), `${name}: missing value cannot inherit a default`);
+ for(const value of [rule.minimum - 1, rule.maximum + 1, 1.5, null])
+ {
+ assert(!valid('RealtimeSettings', { ...settings, [name]: value }), `${name}: invalid bound/type accepted`);
+ }
+}
+for(const change of [
+ { maxQueuedFrameBytes: 8192 },
+ { maxStagedBytes: 1 },
+ { downloadTimeoutMillis: 1000 },
+ { heartbeatTimeoutSeconds: 10 },
+ { reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 }
+])
+{
+ assert.throws(() => assertPolicy({ ...settings, ...change }, hello.payload.limits));
+}
+for(const name of schema.$defs.RealtimeClientLimits.required)
+{
+ assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[name] - 1 }), name);
+}
+assertPolicy({ ...settings, maxConcurrentDownloads: 16, maxConcurrentPrints: 16, maxCopies: 32767 }, hello.payload.limits);
+assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: [] } }));
+assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: ['CANCEL_SPOOL'] } }));
+assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, limits: {} } }));
+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: {} }));
+
+const clientFixtures = ['hello', 'state-applied', 'job-event', 'command-receipt', 'heartbeat', 'inventory', 'ping'];
+const serverFixtures = ['welcome', 'desired-state', 'job-available', 'command', 'settings', 'ack', 'error', 'pong'];
+for(const [files, accepted, rejected] of [
+ [clientFixtures, 'RealtimeClientMessage', 'RealtimeServerMessage'],
+ [serverFixtures, 'RealtimeServerMessage', 'RealtimeClientMessage']
+])
+{
+ for(const name of files)
+ {
+ const message = fixture(`realtime-${name}.message.json`);
+ assert(valid(accepted, message), `${name}: ${ajv.errorsText()}`);
+ assert(!valid(rejected, message), `${name}: wrong-direction frame accepted`);
+ assert(Buffer.byteLength(JSON.stringify(message), 'utf8') <= settings.maxFrameBytes);
+ const missing = { ...message };
+ delete missing.payload;
+ assert(!valid(accepted, missing), `${name}: missing payload accepted`);
+ assert(!valid(accepted, { ...message, contentUrl: 'https://unrelated.invalid/document' }));
+ }
+}
+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(fixture('realtime-ack.message.json').payload.acknowledgedMessageId, event.messageId);
+assert.equal(fixture('realtime-error.message.json').messageId, event.messageId, 'Frame errors retain request correlation.');
+const largeJob = fixture('realtime-large-pdf.response.json');
+assert(valid('PrintJob', largeJob), ajv.errorsText());
+assert.equal(largeJob.contentLength, settings.maxPdfBytes);
+assert(largeJob.contentLength > 20 * 1024 * 1024);
+assert(valid('PrintOptions', { copies: 32767 }));
+assert(!valid('PrintOptions', { copies: 32768 }));
+console.log('Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned.');
diff --git a/tools/verify-schema.mjs b/tools/verify-schema.mjs
index 96c26f8..f8d111a 100644
--- a/tools/verify-schema.mjs
+++ b/tools/verify-schema.mjs
@@ -1,8 +1,12 @@
+// Copyright 2026 Kingsrook, LLC
+// SPDX-License-Identifier: Apache-2.0
+
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { fixtureTypes } from './fixture-types.mjs';
+import './verify-realtime.mjs';
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -36,4 +40,50 @@ assert(validatePrinters({ ...printerPage, nextCursor: 'opaque-server-cursor' }))
assert(!validatePrinters({ ...printerPage, nextCursor: 17 }));
assert(!validatePrinters({ ...printerPage, nextCursor: 'x'.repeat(12001) }));
assert(!validatePrinters({ printers: Array(201).fill(printerPage.printers[0]) }));
+const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
+const validControl = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
+assert(!validControl('PrintingSessionIdentity', { tenantId: 'test-tenant' }), 'Display or device identity cannot replace the authoritative principal.');
+assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: '' }));
+assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', deviceId: null }));
+assert(validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', futureResponseField: true }));
+const action = control('action-clear-queue.request.json');
+assert(validControl('AdminDeviceActionRequest', action));
+const withoutIssuedAt = { ...action };
+delete withoutIssuedAt.issuedAt;
+assert(!validControl('AdminDeviceActionRequest', withoutIssuedAt), 'New controls require their durable freshness timestamp.');
+for(const invalid of [
+ { issuedAt: '2026-10-31T16:00:00-05:00' },
+ { issuedAt: null },
+ { action: 'RESTART_SERVICE' },
+ { action: 'CANCEL_SPOOL' },
+ { jobId: 'job-example-1' },
+ { spoolerJobId: '17' },
+ { queueName: 'Some other queue' },
+ { command: 'shell' },
+ { requestId: '../device' }
+])
+{
+ assert(!validControl('AdminDeviceActionRequest', { ...action, ...invalid }), JSON.stringify(invalid));
+}
+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, lastAppliedRevision: -1 }));
+assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: 9007199254740992 }));
+const pollResult = control('control-poll.response.json');
+assert(validControl('DeviceControlPollResponse', { ...pollResult, futureResponseField: true }), 'Additive response fields remain compatible.');
+assert(!validControl('DeviceControlPollResponse', { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) }));
+assert(!validControl('DeviceControlPollResponse', { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: 'REVOKED' } }), 'Revocation is credential invalidation, not reversible desired state.');
+assert(!validControl('DeviceCommand', { ...pollResult.commands[1], jobId: null }));
+assert(!validControl('DeviceCommand', { ...pollResult.commands[0], jobId: 'job-example-1' }));
+assert(!validControl('DeviceCommand', { ...pollResult.commands[0], action: 'CLEAR_QUEUE' }), 'Server queue clear is never an arbitrary client command.');
+const receipt = control('control-cancel-requested.receipt.json');
+assert(!validControl('DeviceCommandReceipt', { ...receipt, outcome: 'PRINT_RECALLED' }));
+assert(!validControl('DeviceCommandReceipt', { ...receipt, message: 'x'.repeat(1025) }));
+assert(!validControl('DeviceCommandReceipt', { ...receipt, completedAt: 'not-a-date' }));
+assert(!validControl('DeviceCommandReceipt', { ...receipt, jobId: 'other-job' }), 'Receipt cannot override its route-bound command target.');
+const history = control('control-commands-page.response.json');
+assert(!validControl('DeviceActionList', { ...history, commands: Array(201).fill(history.commands[0]) }));
+assert(!validControl('DeviceActionList', { ...history, nextCursor: 'x'.repeat(12001) }));
console.log(`Compiled ${Object.keys(schema.$defs).length} JSON Schema 2020-12 definitions; ${Object.keys(fixtureTypes).length} fixtures passed full schema validation.`);