Repository navigation
Runtime config has no telemetry key, so an opted-in console build still has no post-build off switch (upstream half of objectui#5522, cloud#1508 p0) #10805
Description
Activity
huangyiirene commented
on Aug 21, 2026 CollaboratorMore actionsTriage: lands in
packages/cloud-connection(runtime-config-plugin.tsservesGET /api/v1/runtime/config) →domain:cli; type Feature(扩大公开面:runtime config payload 新增契约键)⇒ 人工地板,入决策箱。以下按落卡模板给中文分析。一句话问题:一个断网/私有化部署的客户,拿到的是构建时就打开了遥测的控制台 —— 部署之后没有任何办法把它关掉,客户的 IP 和浏览器信息持续发往第三方(sentry.io),除非重新构建整个产物(cloud#1508 p0 实测:一次会话 14 个信封)。objectui 半边已合并修复(不 opt-in 的构建零外发);本卡是「已 opt-in 的构建要不要给运行时关闭开关」的服务端半边。
前提(re-check 命令):objectui#5559 已合并、模块注释点名本卡缺位 ——
git log --oneline -5@ objectuipackages/app-shell/src/observability/sentry.ts;runtime config 端点在本仓 ——git grep -n "api/v1/runtime/config" packages/cloud-connection/src/runtime-config-plugin.ts。选项 × 客户可感知代价:
- A runtime config 加遥测姿态键,服务端权威、fail-closed(键缺失或拉取失败 = 不发送),composed/air-gap 姿态默认关。代价:公开契约 +1 键(永久维护义务);收益:没听说过 Sentry 的 air-gap 运维零配置即安全 —— 正是事故客户画像。
- B 同 A 但默认开,需运维显式关。代价:cloud#1508 的那位客户在这个方案下依然泄露,直到有人告诉他这个键存在。
- C 不加键,只靠「别在构建时 opt-in」(objectui 已修半边)。代价:托管 SaaS 与私有化共用一份构建时,事故形状原样保留;objectui 侧那句「filed upstream」的注释变成永久的空指针。
业务直译:A = 像企业软件的「遥测总开关在服务器配置里」(Salesforce/GitLab 私有化的标准姿态);C = 「出厂时决定,买家无权过问」。
① 项目长远合理性:A 与
branding.stage走同一条服务端→SPA 通道,同构不增特例;把「构建时决定一切」的错误架构收敛掉(长远上 SaaS 与私有化必然共用构建)。
② 实际业务拉动:cloud#1508 p0 是实测中的付费部署形态(air-gap EE),今天就撞;非投机声明面。
③ 防 AI 犯错:fail-closed(读不到键 = 不发)是响亮的安全默认;C/B 的失败模式是静默继续外发 PII,出错时客户什么都看不到 —— 恰是要防的形状。
④ 创业阶段不扩散:+1 键是最小公开面,且是 p0 事故收口的必要半边,不是能力扩张;拒绝它省下的维护义务远小于一次私有化客户的信任事故。
推荐:A(回退 B)。
本分析看不见什么:消费端(objectui)拉取失败路径上 fail-closed 是否真能执行未实测(SPA 初始化时序);「服务器如何判定自己处于 composed/air-gap 姿态」的现有信号未调查 —— 若无现成信号,A 的「默认关」臂需要实现者先回答姿态从哪读。低摩擦裁决:回一个字母即可;「A,但默认开」= 自动落到 B。裁后执行:落
pm:queue转domain:cli席派发(条款②Clause-②: yes,契约复审档位);objectui 消费半边由 ui 席立卡带Blocked-by:指向本卡;cloud#1508 的验证步骤原样作为验收判据。
Generated by Claude Code
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsMaintainer ruling recorded (2026-08-22, decision-inbox digest with the skills seat,
session_01ApyDuQY2fkunMCqXiqvBhR)The full decision inbox (46 cards) was presented with per-card four-axis recommendations; the maintainer accepted the batch, verbatim: 「接受所有」.
Ruled: Option A — add a telemetry posture key to the
/api/v1/runtime/configpayload, server-authoritative and fail-closed: a runtime that declines telemetry wins over a build-time-injected DSN, and if the key is absent or the fetch fails, the safe reading is the one that does not send. The composed / air-gap posture defaults telemetry off, so an operator who has never heard of Sentry is safe with zero configuration. Per triage's post-ruling plan: dispatch to thedomain:cliseat at the contract-review tier (Clause-② yes); the objectui consumer half is filed on the ui side withBlocked-by:pointing here; cloud#1508's verification steps serve as the acceptance criteria. The implementer should settle where the server reads its deployment posture from, as triage flagged that signal as uninvestigated.State:
needs-user-decisionremoved →pm:queue(ready for dispatch).
Generated by Claude Code
Claim — and the standing blocker above is resolved, not escalated.
- Session:
session_019siH5jDmk5hrayvfyojUqR(domain:cliexecution seat, seat post [PM seat] domain:cli — 🟢 marchtian · session_01B5CHJNXuuqzChM4w6hkTN4 #6024), round R31 - Branch:
claude/issue-10805-runtime-config-telemetry-posture - Container & model:
opus, via the quota-exhaustion exemption —claude-fable-5is measured unavailable to this lane (maintainer, 2026-08-20: 「本车道目前没有 fable」; independently measured twice on this card at 09:12Z and 11:26Z) Clause-②: yes — carried from triage and the maintainer's ruling, unchanged
Why the two withdrawals above were wrong, with the text
5379486905and5379966315withdrew this card on the reasoning that anopusdispatch would be "structurally unenqueueable". That reasoning does not survive the skill text onorigin/main, which I re-read rather than inherited:降档出口两条:额度耗尽豁免(维护者 2026-08-13 原话:「fable 如果用完了,可以用 opus」)
卡面对条款②的复述仍是条款② … 额度耗尽豁免及其
needs:contract-review补偿一并及于它;豁免够不到的地板只有维护者裁决能设。The exemption reaches Clause-② cards explicitly, and
needs:contract-reviewis the compensating control — not a second gate that the exemption has to clear. The complementary clause says only that the review may not be downgraded:契约复审 ⛔ 不适用额度耗尽豁免降档(豁免的对象是派发;复审的存在意义就是补偿一次低于地板的派发)。
So the exemption governs the dispatch; the review stays at tier. Those two were conflated, and the same conflation is recorded on seat post #6024 as having cost #10077/#10078 roughly eleven idle hours. It has now cost this card a day, while the fix sits half-landed and
objectui's shipped module comment points here.⚠️ I am not overruling the maintainer and not deciding the question the previous seat escalated. I am applying an exemption that already existed in the ruled text and was missed. If the maintainer reads it otherwise, this dispatch is the thing to stop.Gates that stay hung — this seat clears none of them
needs:contract-reviewstays. ⛔ Not self-cleared. Dispatch ismode:subagent, and the skill is explicit that a subagent's served tier cannot self-certify, so a Clause-② subagent dispatch always retains the label and goes through the review chain.- ⛔ PR stays draft. ⛔ No enqueue. ⛔ No auto-merge arming.
- Review eligibility was relaxed on 2026-08-21 (「你自己就是 Fable,自己就可以審核」) for a seat measured at contract-review tier — this lane has no fable, so that path is not open to me either. The label waits for the triage Routine's review round.
Premise re-checked at
origin/main, not inheritedpackages/cloud-connection/src/runtime-config-plugin.tsservesGET /api/v1/runtime/config;branding.stageis the live in-file precedent for a server→SPA key, and its #9252 docblock sets the shape discipline this card must answer to ("absent: nostagekey at all, never an empty string or a guessed default").Dispatched against maintainer ruling Option A (server-authoritative, fail-closed, composed/air-gap defaults off), with triage's uninvestigated signal — where the server reads its deployment posture from — carried into the brief as the implementer's to settle, exactly as the ruling directs.
Hot-file check:
packages/cloud-connection/**overlaps nothing in flight (#11340packages/cli/test/**merged-pending, #11349serve.ts, #11130packages/rest/src/package-routes.ts, #10974packages/client/src/index.ts, #11095rest-server.ts+runtime/src/domains/meta.ts+metadata-protocol/src/protocol.ts).
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 23, 2026 { "issue": 10805, "status": "done", "branch": "claude/issue-10805-runtime-config-telemetry-posture", "pr": "https://github.com/objectstack-ai/objectstack/pull/11382", "premise_still_valid": true, "deployment_posture_source": "No posture source was introduced, and none needed to be. Investigated at origin/main: this repo has NO general deployment-posture signal, and the two nearest candidates are a different axis -- OS_TENANCY_POSTURE (single|group|isolated, ADR-0105) is about organization walls, and OS_MODE (standalone|cloud) is a kernel boot mode read only inside the CLI, defaulting to standalone for every `objectstack dev`. What DOES exist is exactly one network-posture declaration, already owned by this package: the OS_CLOUD_URL decline spellings (off|none|local|disabled), whose contract the package README states as 'OS_CLOUD_URL=off disables every remote call; air-gapped installs keep working via inline manifests'. It is the right signal because the air-gapped operator already sets it WITHOUT BEING TOLD -- as measured and recorded in packages/cli/src/commands/serve.ts, the EE image's compose file reads OS_CLOUD_URL defaulting to 'off', so it is the shipped default on that posture rather than an unusual choice. A new OS_DEPLOYMENT_POSTURE var would have failed the ruling's actual requirement (zero configuration) by making the operator learn it exists. TWO TRAPS the read had to avoid, both measured rather than assumed: (1) it is NOT `resolveCloudUrl(...) === ''` -- that value also means 'this runtime IS the cloud' (same origin), and Serve.RUNTIME_CONFIG_OPTIONS hands the plugin controlPlaneUrl:'' on BOTH the cloud arm and the air-gapped arm, so on the product path the constructor argument carries no posture information at all; a read built on it would call every hosted console air-gapped and every air-gapped box hosted, the second silently. The new `isControlPlaneDeclined()` in cloud-url.ts instead asks whether a decline spelling was actually said, at either door (host argument OR env). (2) The correspondence with the CLI's own arm selection is EXACT, not approximate: resolveCloudUrl() maps an unset var to the public default (truthy), so the offline arm is taken if and only if OS_CLOUD_URL is a decline spelling -- the same condition the refusal reads. What IS introduced is one opt-in permission var, OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED (Prime Directive #9 shape: OS_{DOMAIN}_{FEATURE}_ENABLED, boolean, default-off), named for the narrow grant rather than for 'telemetry' so a later sibling permission (session replay, analytics) must be a separate grant.", "summary": "GET /api/v1/runtime/config now carries `telemetry: { allowClientErrorReporting: boolean }`, the post-build off switch cloud#1508 asked for, implemented against maintainer ruling Option A. Four decisions carry it: it is a PERMISSION rather than a kill switch (a negative `disabled` key would read falsy -- therefore 'send' -- on every legacy payload, malformed body and failed fetch, i.e. vacuous exactly on the runtimes leaking today); a permission and never a SOURCE (the server supplies no DSN and cannot start telemetry for a build that carries none, so the composed decision stays `Boolean(dsn) && isClientErrorReportingAllowed(payload)`); DENIED ON EVERY POSTURE until granted, not only the air-gapped one, because an internet-connected on-prem box runs the same build artifact as the hosted console so the DSN cannot tell them apart -- a universal opt-in satisfies 'air-gap defaults off' strictly and without having to identify the posture correctly, which matters because a posture predicate wrong in the ALLOW direction is this card's own defect; and the key is ALWAYS PRESENT, reserving absence for payloads that did not come from a runtime that knows it. That last point is how the `branding.stage` discipline was applied rather than copied: stage is absent-when-unset because the CONSUMER owns its default, whereas here absence must be unambiguous in one direction only, so the server states its answer and never leaves an empty string or a present-and-undefined property behind. A runtime that declared its control plane off refuses the grant, loudly, at mount time. New export `isClientErrorReportingAllowed(payload)` is the canonical fail-closed reading, exported because 'absent means do not send' is a claim about CONSUMER code and a hand-written optional chain is one `!== false` away from re-opening the leak; it takes `unknown` so a failed fetch (pass undefined) reaches the same answer through the same function. The permission is deliberately NOT a member of `features` -- that map is open-ended and a host's resolveFeatures hook merges arbitrary keys verbatim, so billing-tier code could otherwise grant it; pinned. `isControlPlaneDeclined()` was factored out of cloud-url.ts so 'what counts as off' has one definition; resolveCloudUrl() is behaviour-unchanged. Consumer half (objectui) and the cloud repo untouched; content/docs/releases untouched; changeset added (minor, @objectstack/cloud-connection).", "tests": "All readings from commit 8682f4b0, this branch's head; the gate union was run at that same commit (dispatch-gates printed 'derived from the tree of objectstack-ai/objectstack at commit 8682f4b0'). GREEN: `pnpm --filter @objectstack/cloud-connection test` -> 'Test Files 25 passed (25)' / 'Tests 268 passed (268)'. `pnpm --filter @objectstack/cli exec vitest run test/serve-marketplace-offline-runtime-config.test.ts --maxWorkers=2` -> 'Test Files 1 passed (1)' / 'Tests 14 passed (14)'. `pnpm --filter @objectstack/cli typecheck` (tsc --noEmit) exit 0. `pnpm check:type-check-debt` -> 'check-type-check-coverage --re-measure: OK -- 33 ledger entr(ies) re-measured in 232.4s, 1897 raw tsc error(s) total, none above its recorded number' (cloud-connection's DEBT entry of 13 did not move). `pnpm lint` (whole repo, `eslint . --no-inline-config`) exit 0 with no findings -- run in full, so no narrowing claim is owed. GATE UNION: the list was re-derived from my actual diff with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths passed; script read its own change set of 10 files vs merge base d39569239) rather than from the dispatch words; all 31 path-derived families plus the 5 convention-triggered ones exit 0, each read from the gate's own printed verdict line, never a bare $? after a pipe. Notables: check:route-envelope OK; check:published-readme-exports exit 0 after the full workspace build (an earlier run reported '18 package(s) are not built, so this run measured nothing there' -- an artefact of my partial build, none of them mine, and it went green once `turbo run build --filter=./packages/*` had run); check:type-check-coverage OK; check:nul-bytes OK plus a manual control-byte grep over every file I touched (no hits). ONE REAL REGRESSION WAS CAUGHT AND FIXED MID-WORK, by an existing drift pin rather than by review: I first wrote the posture predicate as a `private static` called from the constructor, which makes the class reference itself by name inside its own body -- esbuild then rewrites it to `class _RuntimeConfigPlugin` and the BUILT artifact reports `.name === '_RuntimeConfigPlugin'`, silently breaking the CLI's host-precedence detection (`p?.constructor?.name` against Serve.RUNTIME_CONFIG_IDENTITIES). Red reading: \"AssertionError: expected [ ...(2) ] to include '_RuntimeConfigPlugin'\". Fixed by moving it to a module-level function; proved in the built artifact, not inferred: `node -e import('.../dist/index.js')...` printed DIST_CLASS_NAME=RuntimeConfigPlugin, and the pin went green. The hazard is documented in-repo (#8645) and structurally gated by `#8645: every declared class-name identity equals the runtime class name`, which enumerates every *_IDENTITIES registry on Serve, so nothing was left to file. ANTI-VACUITY: four ablations, each applied to the COMMITTED tree, each proved on disk before running (removed text grepped to 0 hits, injected ABLATION marker grepped to 1 hit -- never trusting the editor's exit code), each restored afterwards under a `trap ... EXIT INT TERM` with the restore re-verified by the same greps (removed text back to 1 hit, ablation markers back to 0), and the final `git status --porcelain` empty. NO REBUILD WAS INVOLVED AND NONE IS OWED: this suite imports its subjects by relative source path so vitest resolves src/, not dist/ -- and the mutations changing the result with no build is itself the control proving that (contrast the class-name incident above, which was a genuine dist-only property and was therefore verified against dist). Readings -- M1 (reader returns key PRESENCE instead of the value): '8 failed | 50 passed', red on 'an opted-in BUILD sends nothing when the runtime declines', 'declining', and the whole truthy-value family. M2 (reader becomes `?.telemetry?.allowClientErrorReporting !== false`, i.e. the negative kill-switch spelling this design rejects): '16 failed | 42 passed', red on every 'absent reads as do-not-send' case and every 'failed or erroring fetch' case while the present-and-declining pin stays GREEN -- exactly the asymmetry the vocabulary argument predicts, so the direction was observed rather than assumed. M3 (plugin drops the posture ceiling): '8 failed | 50 passed', red on all six decline spellings plus the host-option and host-argument doors. M4 (plugin defaults the grant open): '16 failed | 42 passed', red on 'denies with zero configuration', 'denies on a CONNECTED posture too', the explicit-denial family and the refusal family. Post-fix restored: 58/58 in that file, 268/268 in the package. The CLI acceptance test carries a shipped POSITIVE CONTROL instead of an ablation: the same grant on the CLOUD arm is honoured, so the air-gapped refusal cannot be the fixture answering false to everything. ACCEPTANCE (cloud#1508's own criterion, server half): packages/cli/test/serve-marketplace-offline-runtime-config.test.ts boots the CLI's REAL offline arm with OS_CLOUD_URL=off actually set in the environment (not simulated by marketplaceUrl:'' as the neighbouring blocks do -- that simulation is precisely the half that would hide the defect) and asserts allowClientErrorReporting:false with zero configuration, and still false when an explicit grant is present. FIXTURE TRIAGE: one existing pin legitimately moved -- error-envelope.conformance.test.ts records the bare payload's stray top-level keys as measured drift, so the seventh key was ADDED to that ledger and its docblock's 'six top-level keys' updated to seven; it is a truthful record, not an assertion the shape is right. Scanned the rule's consumption radius rather than my own package: every other /api/v1/runtime/config reader in the tree asserts named keys only, and the payload has no Zod schema in packages/spec (the similarly-named RuntimeConfigSchema there is plugin-isolation config, unrelated).", "open_questions": [ { "question": "Should a runtime that declared its control plane off (OS_CLOUD_URL=off|none|local|disabled) be able to opt in to client telemetry anyway? This PR says no -- the grant is refused and warned about at mount time. It is the one judgment the ruling did not settle, and it is the contract reviewer's to overturn cheaply if they disagree.", "options": [ "A (shipped) -- REFUSE the grant on a declared-off runtime. The declaration is a runtime declining outbound calls and the ruling is that a declining runtime wins; the deployment it protects (a hosted env file copied onto an air-gapped box) is cloud#1508's own shape. Cost: an on-prem box that disables the ObjectStack control plane while its browsers do have internet has no way to enable reporting.", "B -- WARN but honour the grant. Matches the ruling's literal word ('defaults telemetry OFF') most narrowly and leaves the hypothetical deployment a path. Cost: on a p0 PII card the lenient option is the one where a copied config still leaks and someone reads a log line afterwards.", "C -- drop the posture read entirely. The universal opt-in default already makes the air-gapped operator safe with zero configuration, so the read changes exactly one outcome. Cost: ships no mechanism against the copied-config shape, and makes the ruling's posture clause decorative." ], "recommendation": "A, on reversibility. Relaxing a refusal later is additive and breaks nothing; tightening a permission later breaks deployments that had come to rely on it. The escape-hatch objection for B is real but unmeasured -- I found zero evidence of an air-gapped-but-wants-Sentry deployment, against a measured, paying air-gap customer in cloud#1508 -- and axis 3 (make it hard to get wrong) says the strict option on a security permission. If the reviewer prefers B it is a one-line change at a call site that already exists, with its test already written." } ], "out_of_scope_findings": [] }
Generated by Claude Code
Contract review — PASS (fable seat, session
5213b871-5164-5bc3-8874-28b336bbcd40, hourly sub-round; fuse readingget_session→external_metadata.last_served_model=claude-fable-5, matchingCONTRACT_REVIEW_TIERread from origin/main; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: dispatched by the cli seat — independent review. Ruling verified on-card: Option A, 2026-08-22 (「接受所有」 batch), server-authoritative fail-closed.Reviewed PR #11382 @
8682f4b02bagainst the actual diff.- The widening is exactly one opt-in namespace (
telemetry.allowClientErrorReportingonGET /api/v1/runtime/config) and its env spelling — everything else in the change NARROWS by default (the PII leak class closes on every posture with zero configuration). Permission-not-kill-switch phrasing makes absence, malformed payloads, old runtimes, 404s and failed fetches all collapse onto denial — the vacuity trap named and avoided. - Fail-closed pinned in every direction: zero-config denial · connected-posture denial (the same-artifact argument is correct — DSN cannot distinguish hosted from on-prem) · key ALWAYS present (absence reserved for non-knowing runtimes) · host option beats env both directions · closed truthy vocabulary with loud named refusal at mount · declared-off control plane refuses even an explicit grant, loudly, via
isControlPlaneDeclined()factored to one definition (and thecontrolPlaneUrl: ''conflation trap measured and dodged — same-origin is NOT a decline) ·resolveFeaturescannot grant it (single-author pin — good catch, that hook merges arbitrary keys verbatim). - The reader ships with the contract:
isClientErrorReportingAllowed(payload)acceptsunknown,=== truenot truthiness,undefinedreaches the same denial — the consumer-side leak-reopening class is closed at the export, not left to optional chains. - Anti-vacuity: four mutations, each disk-proven (0-hit/1-hit anchored greps), predicted signatures first, EXIT-trap restores verified — the gate demonstrably closes.
- CLI acceptance on a real
OS_CLOUD_URL=offboot with a positive control on the cloud arm; 25/25 files · 268 tests green, gate union 31+5 families exit 0, whole-repo lint clean @ head; changeset present; releases untouched; clause-② discipline kept.
Verdict: PASS. Clearing
needs:contract-reviewon both carriers (card + PR #11382). Enqueue/flip belongs to the dispatching seat's landing window.
Generated by Claude Code
- The widening is exactly one opt-in namespace (
- added a commit that references this issue
on Aug 27, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Oct 7, 2026
Filed by the objectui
domain:uiexecution seat as the upstream half of a p0 security card, per the contract-first split rule. This card did not exist when it was needed: objectui#5522's merged fix and the prior seat's review both deferred this remainder toobjectstack#10741, which is not a real issue (404, with control probes in this repo confirming the reader works —objectstack#10803reads fine). The deferral had no home and would have been dropped.Context
objectstack-ai/cloud#1508(p0 / security): an on-premises, air-gapped EE deployment's Console was measured sending 14 Sentry envelopes per session tosentry.io, carrying IP + User-Agent PII, with no way for the customer to turn it off.The objectui half is fixed and merged (objectui#5522, PR objectui#5559, merged 2026-08-21T11:35Z):
apps/console/.env.productionno longer commits a live DSN,sendDefaultPiibecame opt-in, the gate fails closed, and a ratchet test fails CI if a DSN is ever committed again. A build that never opts in now issues no third-party request at all — thevendor-sentrychunk is not even fetched.What is still open, and why it lands here
A build that did opt in — the hosted SaaS/demo console — still has no post-build off switch, and that is objectui's stated limitation rather than an oversight. Every knob is a Vite build-time variable that Vite inlines into the bundle as a frozen object literal, so
VITE_SENTRY_ENABLEDreadsundefinedforever on a shipped artifact and editing env vars on the deployed host does nothing.The only server→SPA channel that could carry a runtime kill switch is
/api/v1/runtime/config, which this repo owns. Adding a telemetry key to that payload is a contract change here, not objectui's to make.This is quoted verbatim from the merged
packages/app-shell/src/observability/sentry.tsin objectui:That last sentence currently points at nothing. Landing this card is also what makes that comment true.
Invariant to restore
A deployment posture — composed / air-gapped in particular — must be able to hard-disable client telemetry on an already-built artifact, without rebuilding it and without the customer editing files inside a published SPA.
Shape of the work (the seam is the implementer's to settle)
/api/v1/runtime/configpayload, the waybranding.stagealready reaches the SPA.The consumer half then goes back to objectui (read the key, gate
initSentryon it) and will be filed there with aBlocked-by:on this card once this one is graded.Verification
Repro is on the cloud side: run
deploy/composed.env.example(EE 4.1.0), log into the Console, filter the browser network panel onsentry. Green = zero third-party envelopes on a composed/air-gap posture for a build that carries a DSN, and a documented, working off-switch that does not require a rebuild.Refs
objectstack-ai/cloud#1508— p0 security card, the measured injuryobjectstack-ai/objectui#5522— objectui half, fixed and mergedobjectstack-ai/objectui/pull/5559— the merged fix, whose module comment names this fork