From 43d03d141b5a8b7f84a3bc40d4949906298691cf Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:18:51 +0700 Subject: [PATCH 1/9] docs: write the planned model release in the future tense README, LICENSE, py/LICENSE and SECURITY now say plainly that no model has been released, no date is set and nothing is on sale, and that each release will carry its own license. The base-model attribution is stated as planned. No claim is made about quality or performance, and nothing was removed. No test or gate held the old wording. The CHANGELOG records the change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ LICENSE | 13 +++++++------ README.md | 8 ++++---- SECURITY.md | 5 +++-- py/LICENSE | 13 +++++++------ 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d93df3..72b87de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,10 @@ the pointer to it. ### Added +- The open-weights plan is now written in the future tense in `README.md`, `LICENSE`, `py/LICENSE` and `SECURITY.md`: + planned, not released, no release date set, nothing on sale, each release to carry its own license, and no claim about + the model's quality or performance. The free-weights-first intent stays; nothing was removed. No test or gate held the + old wording. - The pricing page is now `/pricing` (`web/pricing.html`), and `/plans`, `/plans/` and `/plans.html` answer a 301 to it from the new `web/_redirects`, because the old address is the one given to the payment provider. The sitemap lists `/pricing`. Rule 18 of `scripts/surface-check.mjs` holds the redirect file (valid lines, same-site destinations that are diff --git a/LICENSE b/LICENSE index 7bbb734..efe23a9 100644 --- a/LICENSE +++ b/LICENSE @@ -9,17 +9,18 @@ modify, distribute, sublicense, sell, or create derivative works from this repository's contents, except as permitted by applicable law (fair use / quotation with attribution). -MODEL WEIGHTS ARE LICENSED SEPARATELY. Any Kolwen model weights are released -under their own license, stated in and shipped with each release. Nothing in -this file grants or restricts rights to model weights. +MODEL WEIGHTS WILL BE LICENSED SEPARATELY. No Kolwen model weights have been +released yet and no release date is set. If and when any are released, each +release will carry its own license, stated in and shipped with that release. +Nothing in this file grants or restricts rights to model weights. No trademark rights are granted. "Kolwen", "โคลเวน", "Kolwen Peat", "Kolwen Lignite", "Kolwen Anthracite", "Bankfire", and "TheColliery" are names claimed by the copyright holder. -Kolwen models are built on Qwen (Alibaba Cloud) base models; the specific -base model used for each release is Apache-2.0, and base-model attribution -plus the Apache-2.0 notice ship with every weights release. +Kolwen models are planned to be built on Qwen (Alibaba Cloud) base models; the +base model for each release is expected to be Apache-2.0, and base-model +attribution plus the Apache-2.0 notice will ship with every weights release. THE CONTENTS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER diff --git a/README.md b/README.md index 4c37c98..a367ade 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ downloadable yet, as the Status section says. | model | intended seat | |---|---| -| **Kolwen** | the free model—open weights, run it yourself | +| **Kolwen** | planned: a free model with open weights to run yourself; not released, no date set, nothing on sale | | **Kolwen Peat** | smallest of the fired line | | **Kolwen Lignite** | the workhorse | | **Kolwen Anthracite** | the top—highest-grade coal, hottest and cleanest burn | @@ -24,8 +24,8 @@ is a named rank with no model behind it yet—reserved, not shipped. **Pre-release.** Nothing is downloadable yet. This repository is the product's public face—documentation, releases, and community—in the same shape as [`anthropics/claude-code`](https://github.com/anthropics/claude-code): the kitchen is not here. -- Built on Qwen (Apache-2.0 base)—base attribution ships with every release. -- Model weights, when released, carry their own license per release. +- Planned to be built on Qwen (Apache-2.0 base)—base attribution will ship with every release. +- No model weights have been released and no release date is set. If and when weights are released, each release will carry its own license. - Issues and pull requests are welcome now—see [CONTRIBUTING.md](CONTRIBUTING.md), which asks you to open an issue first. @@ -36,6 +36,6 @@ Bugs and feature discussion belong in Issues. ## License -Repository contents: all rights reserved—see [LICENSE](LICENSE). Released model weights carry their own per-release license. +Repository contents: all rights reserved—see [LICENSE](LICENSE). No model weights have been released; if and when they are, each release will carry its own license. Portions generated with AI assistance, reviewed by the maintainer. Copyright (c) 2026 HetCreep—see [NOTICE](NOTICE). diff --git a/SECURITY.md b/SECURITY.md index 2e63fdb..39dbc41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,8 +43,9 @@ This repository is the public face of Kolwen. What ships from it: weakened path filter or config is as much a finding as a change to a workflow itself. **Out of scope** -- **The model.** Kolwen is in development and no model, weights, or inference service has been - released. There is nothing here that runs a model. +- **The model.** Kolwen is in development. No model, weights, or inference service has been + released and no release date is set, so there is nothing here that runs a model. If and when + one is released, this scope will be updated. - Vulnerabilities in third-party services this repo depends on—Cloudflare, GitHub, PyPI, npm, Google Fonts. Report those to the vendor. - The npm package `kolwen`, which is published from outside this repository. It is ours, so diff --git a/py/LICENSE b/py/LICENSE index 7bbb734..efe23a9 100644 --- a/py/LICENSE +++ b/py/LICENSE @@ -9,17 +9,18 @@ modify, distribute, sublicense, sell, or create derivative works from this repository's contents, except as permitted by applicable law (fair use / quotation with attribution). -MODEL WEIGHTS ARE LICENSED SEPARATELY. Any Kolwen model weights are released -under their own license, stated in and shipped with each release. Nothing in -this file grants or restricts rights to model weights. +MODEL WEIGHTS WILL BE LICENSED SEPARATELY. No Kolwen model weights have been +released yet and no release date is set. If and when any are released, each +release will carry its own license, stated in and shipped with that release. +Nothing in this file grants or restricts rights to model weights. No trademark rights are granted. "Kolwen", "โคลเวน", "Kolwen Peat", "Kolwen Lignite", "Kolwen Anthracite", "Bankfire", and "TheColliery" are names claimed by the copyright holder. -Kolwen models are built on Qwen (Alibaba Cloud) base models; the specific -base model used for each release is Apache-2.0, and base-model attribution -plus the Apache-2.0 notice ship with every weights release. +Kolwen models are planned to be built on Qwen (Alibaba Cloud) base models; the +base model for each release is expected to be Apache-2.0, and base-model +attribution plus the Apache-2.0 notice will ship with every weights release. THE CONTENTS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER From 81646521b6ca105619cc5ab8e14ee9a2182569d6 Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:19:57 +0700 Subject: [PATCH 2/9] docs(terms): fill the enforcement process numbers and the cancellation channel, with a Thai version TERMS.md's enforcement process now states its numbers: 14 business days to answer a notice, a written decision 7 business days after the answer, 30 business days to file the one appeal through the appeals role address, an answer within 14 business days, the key's clock stopped for the whole case, a 90-calendar-day ceiling from the notice, and a missed Kolwen deadline lifting the suspension while a lapsed customer deadline lets the case proceed. A business day is a normal working day in Thailand without the Royal Gazette public holidays. A Thai version follows the English. The dormant Thai 7-day clause names the support role address in both languages. Everything stays under the pending-legal-review marker, and the support spec's appeal deadline now matches. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 14 +++++++---- TERMS.md | 49 ++++++++++++++++++++++++++++++-------- docs/SUPPORT-AGENT-SPEC.md | 2 +- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b87de..401b62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,13 @@ the pointer to it. ### Added +- The enforcement process in `TERMS.md` now has its numbers, in English and in a new Thai version: 14 business days to answer + a notice, a written decision 7 business days after the answer, 30 business days to file the one appeal through the + appeals role address, an answer within 14 business days, the key's clock stopped for the whole case, a ceiling of 90 + calendar days from the notice, and a missed deadline on Kolwen's side lifting the suspension. A business day is a normal + working day in Thailand, Monday to Friday, without the public holidays announced in the Royal Gazette. The dormant Thai + 7-day clause names the support role address as its cancellation channel. All of it stays under the pending-legal-review + marker and unlinked. - The open-weights plan is now written in the future tense in `README.md`, `LICENSE`, `py/LICENSE` and `SECURITY.md`: planned, not released, no release date set, nothing on sale, each release to carry its own license, and no claim about the model's quality or performance. The free-weights-first intent stays; nothing was removed. No test or gate held the @@ -188,10 +195,9 @@ the pointer to it. `TERMS.md`. - `TERMS.md` also carries the enforcement process for a customer's fault as due process (notice, suspension rather than termination, a window to answer, a written decision, one appeal to a human, a register of cases; a chargeback - suspends and is restored or terminated by how the dispute closes, on Paddle's track). The day counts and the appeal - channel are placeholders. The Thai 7-day cancellation right is written in both languages as a dormant clause that - takes effect on incorporation, direct-marketing registration or revenue above 1,800,000 baht a year; until then the - refunds above are the live rule. Nothing flips the switch yet. + suspends and is restored or terminated by how the dispute closes, on Paddle's track). The Thai 7-day cancellation right is + written in both languages as a dormant clause that takes effect on incorporation, direct-marketing registration or + revenue above 1,800,000 baht a year; until then the refunds above are the live rule. Nothing flips the switch yet. - `PRIVACY.md` names Cloudflare Web Analytics (aggregate statistics only: page, referrer, country, browser; no one identified; counts run low behind an ad-blocker) and adds a short Age section. Its sentence about an injected bot-detection script is replaced, because the zone's JavaScript Detections were switched off on 2026-10-07 and a diff --git a/TERMS.md b/TERMS.md index 5cd7601..45a0232 100644 --- a/TERMS.md +++ b/TERMS.md @@ -153,23 +153,52 @@ has broken a rule, which is a process, in this order: 1. **Notice.** Kolwen tells you which clause it believes you broke and gives a summary of the evidence. 2. **Suspension, not termination,** while the case is open. If the product is a key, its clock of days - stops while the account is suspended. -3. **A window to answer.** You have **[N days]** to respond. -4. **A written decision.** Either the case is dismissed and the account and its key are restored in - full, or it is upheld and the contract is terminated. The refund follows the outcome: dismissed means - full restoration; upheld means no refund. -5. **One appeal to a human** against an upheld decision, through **[appeal channel]**. + stops for the whole case. +3. **A window to answer.** You have **14 business days** to answer the notice. +4. **A written decision,** within **7 business days** after your answer. Either the case is dismissed and + the account and its key are restored in full, or it is upheld and the contract is terminated. The refund + follows the outcome: dismissed means full restoration; upheld means no refund. +5. **One appeal to a human** against an upheld decision. You have **30 business days** after the written + decision to file it, through **appeals@kolwen.com**, and Kolwen answers within **14 business days**. 6. **A register of cases** is kept, so that the same conduct draws the same answer. +**Business days.** In this section a business day is a normal working day in Thailand: Monday to Friday, +excluding the public holidays announced by the Prime Minister's Office in the Royal Gazette. The holidays +of banks are not used. + +**A ceiling and what a missed deadline does.** No case runs longer than **90 calendar days**, counted from +the date of the notice. If Kolwen misses a deadline of its own (the 7 business days for the decision, the +14 business days for the appeal answer, or the 90-day ceiling), the suspension is lifted and the key comes +back into use. If a deadline of yours lapses (the 14 business days to answer, or the 30 business days to +appeal), the case proceeds to its next step. + **Chargebacks run on Paddle's track.** A bank dispute is handled through Paddle as merchant of record, and Paddle asks Kolwen for evidence. The account is **suspended** when a chargeback is filed, because the money is gone. It is **restored** if the dispute resolves in Kolwen's favour or you pay again, and **terminated only when the dispute closes against Kolwen.** Kolwen does not argue a chargeback with you directly. +> **[รอที่ปรึกษากฎหมาย] ขั้นตอนเมื่อ Kolwen เห็นว่าลูกค้าฝ่าฝืนกฎ** +> 1. แจ้งข้อกำหนดที่เห็นว่าฝ่าฝืน พร้อมสรุปพยานหลักฐาน +> 2. ระงับบัญชี ไม่ใช่เลิกสัญญา ระหว่างที่เคสยังเปิด ถ้าสินค้าเป็นคีย์ นาฬิกานับวันของคีย์หยุดตลอดทั้งเคส +> 3. ลูกค้ามีเวลา **14 วันทำการ** เพื่อตอบข้อแจ้ง +> 4. คำตัดสินเป็นลายลักษณ์อักษรภายใน **7 วันทำการ** หลังลูกค้าตอบ ยกคำร้องและคืนบัญชีกับคีย์เต็มที่ หรือยืนตามและเลิกสัญญา +> เงินคืนเป็นไปตามผล ยกคำร้องคืนสถานะเต็มที่ ยืนตามไม่คืนเงิน +> 5. อุทธรณ์ต่อมนุษย์ได้หนึ่งครั้งเมื่อคำตัดสินยืนตาม ภายใน **30 วันทำการ** หลังคำตัดสิน ผ่าน **appeals@kolwen.com** และ Kolwen +> ตอบภายใน **14 วันทำการ** +> 6. เก็บทะเบียนเคส เพื่อให้พฤติกรรมเดียวกันได้คำตอบเดียวกัน +> +> **วันทำการ** ในหัวข้อนี้คือวันทำงานปกติในประเทศไทย ตั้งแต่วันจันทร์ถึงวันศุกร์ ไม่รวมวันหยุดราชการที่สำนักนายกรัฐมนตรีประกาศในราชกิจจานุเบกษา +> และไม่ใช้วันหยุดของธนาคาร +> +> **เพดานและผลของการพลาดกำหนด** เคสหนึ่งยาวไม่เกิน **90 วันตามปฏิทิน** นับจากวันที่แจ้ง หาก Kolwen พลาดกำหนดของตนเอง +> (7 วันทำการสำหรับคำตัดสิน 14 วันทำการสำหรับตอบอุทธรณ์ หรือเพดาน 90 วัน) การระงับจะหลุดและคีย์กลับมาใช้ได้ หากกำหนดของลูกค้าหมดลง +> (14 วันทำการสำหรับตอบ หรือ 30 วันทำการสำหรับอุทธรณ์) เคสเดินต่อไปยังขั้นถัดไป + **GAP 9 — [pending legal review]: counsel confirms the notice duties before suspension or termination -(consumer law favours notice first), and the day counts and the appeal channel above are placeholders -the owner signs later.** +(consumer law favours notice first), the definition of a business day against the Civil and Commercial +Code (ss.193/1, 193/4 and 193/8), and how the 90-calendar-day ceiling sits with business-day counting and +a key clock that stops for the whole case.** ## Thai consumer cancellation right — drafted, dormant @@ -186,14 +215,14 @@ selling online only once that person enters the direct-marketing register or ear registration as a direct-marketing business, or (c) annual revenue above 1,800,000 baht. **Until then the live rule is the Refunds section above.** -Once it applies, a customer in Thailand cancels by notice through **[cancellation notice channel]**, and +Once it applies, a customer in Thailand cancels by notice through **support@kolwen.com**, and no consent-to-start waiver reduces the refund inside the 7 days. Kolwen's protection against use followed by a refund is a trial before payment, never a waiver of this right. > **[รอที่ปรึกษากฎหมาย] สิทธิยกเลิกของผู้บริโภคในประเทศไทย — ร่างไว้ ยังไม่มีผลบังคับ** > ตามพระราชบัญญัติขายตรงและตลาดแบบตรง พ.ศ. 2545 ผู้บริโภคที่ซื้อจากผู้ประกอบธุรกิจที่ขายตรงหรือขายผ่านตลาดแบบตรง > ยกเลิกได้ภายใน 7 วันนับแต่ได้รับบริการ และได้รับเงินคืนเต็มจำนวน โดยจ่ายคืนภายใน 15 วัน -> นับแต่ได้รับการบอกเลิก ปัจจุบันสิทธินี้ยังไม่ผูกพัน Kolwen ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่านช่องทาง **[ช่องทางแจ้งยกเลิก]** ข้อนี้จะมีผลเมื่อครบเงื่อนไขข้อใดข้อหนึ่งก่อน คือ ผู้ขายจดทะเบียนเป็นนิติบุคคล +> นับแต่ได้รับการบอกเลิก ปัจจุบันสิทธินี้ยังไม่ผูกพัน Kolwen ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่าน **support@kolwen.com** ข้อนี้จะมีผลเมื่อครบเงื่อนไขข้อใดข้อหนึ่งก่อน คือ ผู้ขายจดทะเบียนเป็นนิติบุคคล > จดทะเบียนเป็นผู้ประกอบธุรกิจตลาดแบบตรง หรือมีรายได้เกิน 1,800,000 บาทต่อปี จนถึงวันนั้นให้ใช้หลักการคืนเงินในหัวข้อ > Refunds เป็นหลัก เมื่อข้อนี้มีผล ไม่มีการสละสิทธิเพื่อลดเงินคืนในช่วง 7 วัน วิธีที่ Kolwen ป้องกันตนเองจากการใช้แล้วขอคืนเงินคือการให้ทดลองใช้ก่อนชำระเงิน > ไม่ใช่การให้ลูกค้าสละสิทธิ diff --git a/docs/SUPPORT-AGENT-SPEC.md b/docs/SUPPORT-AGENT-SPEC.md index aa3cb2e..6f4af9b 100644 --- a/docs/SUPPORT-AGENT-SPEC.md +++ b/docs/SUPPORT-AGENT-SPEC.md @@ -65,7 +65,7 @@ cases. The requirements below are how the support surface behaves around that pr that notice is an open question for counsel, with its timing and content; the process in `TERMS.md` has no such exception, so until counsel decides, notice comes first. 3. **An appeal gets a case number, a status the customer can see, and a decision deadline that Kolwen sets and - publishes** (**[N days]**, a placeholder until signed), **with an escalation when the deadline passes.** + publishes** (the Terms set **14 business days** to answer an appeal), **with an escalation when the deadline passes.** 4. **One case across support, safety and billing.** Every door sees it, and no door sends the customer back to a door already tried. 5. **Money waits for the decision.** No refund is refused while an appeal is open, and a reversed suspension From 9a4514b72845123a6238a48592bb0046b424a601 Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:40:15 +0700 Subject: [PATCH 3/9] feat(legal): the legal-state record and the build-time switch for the Thai 7-day clause The Thai 7-day cancellation clause is in TERMS.md only while legal/legal-state.json says a trigger has been met (incorporation, direct-marketing registration, or a revenue crossing that stands). The record holds state only: fixed keys whose values are null or a real calendar date, plus the seller type, so a registration number, a tax identification number, a revenue figure or any owner identifier cannot be stored in this public repository; the validator refuses any other key or kind of value. The clause text moves to legal/thai-7day-clause.md (English and Thai, with a placeholder for the date it takes effect), and TERMS.md carries one marked block that is empty while no trigger is met and holds the dated clause once one is. scripts/legal-state.mjs --write renders the block from the record and --check refuses a disagreement; the block is never edited by hand. The mechanism is a marked block plus a script because TERMS.md is a tracked document and the site has no build step; its limit is that a person can still edit the block, which the check (and the next commit) refuses rather than prevents. legal/README.md writes the revenue trigger down as a design (settled statements net of refunds in baht, a crossing date that arms the clause at once, a 14-calendar-day veto for a measurement error only, a one-way latch, git history as the audit log); no billing code exists. Red first against a stub that reports nothing: 9 of 10 tests failed (the tenth was tightened to name what it checked); with the script, all 10 pass, and each of 10 mutants (an inactive switch, a veto ignored, an always-rendered clause, the key allowlist, the date shape, the veto window, the marker count, the sync compare, the company check, the nested key check) is killed by a named test. The tests are in the CI roster. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 3 +- TERMS.md | 36 +-------- legal/README.md | 47 +++++++++++ legal/legal-state.json | 7 ++ legal/thai-7day-clause.md | 23 ++++++ scripts/legal-state.mjs | 39 +++++++++ scripts/legal-state.test.mjs | 148 +++++++++++++++++++++++++++++++++++ scripts/lib/legal-state.mjs | 100 +++++++++++++++++++++++ 8 files changed, 368 insertions(+), 35 deletions(-) create mode 100644 legal/README.md create mode 100644 legal/legal-state.json create mode 100644 legal/thai-7day-clause.md create mode 100644 scripts/legal-state.mjs create mode 100644 scripts/legal-state.test.mjs create mode 100644 scripts/lib/legal-state.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bf0b50..5a8a09c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,8 @@ jobs: # bilingual structure · every stated contrast ratio recomputed from its own hex pair. - run: node scripts/surface-check.mjs # The one robots.txt comparison rule the post-deploy check applies (it cannot run here: it needs the live site). - - run: node --test scripts/robots-edge.test.mjs + # And the legal-state switch's tests: the Thai 7-day clause is in TERMS.md only while legal/legal-state.json says a trigger is met. + - run: node --test scripts/robots-edge.test.mjs scripts/legal-state.test.mjs reply-language: name: reply language (LWK-131) diff --git a/TERMS.md b/TERMS.md index 45a0232..11d38a6 100644 --- a/TERMS.md +++ b/TERMS.md @@ -104,8 +104,6 @@ adds to it: **GAP 6 — [pending legal review]: counsel signs the wording of this clause, and confirms the immediate-start consent works as the basis for deducting days used.** -A separate Thai consumer cancellation right is drafted below, dormant. - ## Ending the contract **By Kolwen without cause.** Kolwen refunds the unused part: a key's remaining days, a subscription's @@ -200,38 +198,8 @@ directly. Code (ss.193/1, 193/4 and 193/8), and how the 90-calendar-day ceiling sits with business-day counting and a key clock that stops for the whole case.** -## Thai consumer cancellation right — drafted, dormant - -*This clause is written now and switched on later. It has no effect until the trigger below is met.* - -Under the Direct Sales and Direct Marketing Act B.E. 2545, a consumer who buys from a business that -sells directly or through direct marketing may cancel within **7 days** of receiving the service, and -then gets the **full price back**, paid out within **15 days** of the business receiving the -cancellation notice. As things stand, that right does not bind Kolwen: it applies to a natural person -selling online only once that person enters the direct-marketing register or earns more than -1,800,000 baht a year, and it applies to a company from incorporation. - -**The trigger:** this clause takes effect on the first of (a) incorporation of the seller, (b) -registration as a direct-marketing business, or (c) annual revenue above 1,800,000 baht. **Until then -the live rule is the Refunds section above.** - -Once it applies, a customer in Thailand cancels by notice through **support@kolwen.com**, and -no consent-to-start waiver reduces the refund inside the 7 days. Kolwen's protection against use followed -by a refund is a trial before payment, never a waiver of this right. - -> **[รอที่ปรึกษากฎหมาย] สิทธิยกเลิกของผู้บริโภคในประเทศไทย — ร่างไว้ ยังไม่มีผลบังคับ** -> ตามพระราชบัญญัติขายตรงและตลาดแบบตรง พ.ศ. 2545 ผู้บริโภคที่ซื้อจากผู้ประกอบธุรกิจที่ขายตรงหรือขายผ่านตลาดแบบตรง -> ยกเลิกได้ภายใน 7 วันนับแต่ได้รับบริการ และได้รับเงินคืนเต็มจำนวน โดยจ่ายคืนภายใน 15 วัน -> นับแต่ได้รับการบอกเลิก ปัจจุบันสิทธินี้ยังไม่ผูกพัน Kolwen ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่าน **support@kolwen.com** ข้อนี้จะมีผลเมื่อครบเงื่อนไขข้อใดข้อหนึ่งก่อน คือ ผู้ขายจดทะเบียนเป็นนิติบุคคล -> จดทะเบียนเป็นผู้ประกอบธุรกิจตลาดแบบตรง หรือมีรายได้เกิน 1,800,000 บาทต่อปี จนถึงวันนั้นให้ใช้หลักการคืนเงินในหัวข้อ -> Refunds เป็นหลัก เมื่อข้อนี้มีผล ไม่มีการสละสิทธิเพื่อลดเงินคืนในช่วง 7 วัน วิธีที่ Kolwen ป้องกันตนเองจากการใช้แล้วขอคืนเงินคือการให้ทดลองใช้ก่อนชำระเงิน -> ไม่ใช่การให้ลูกค้าสละสิทธิ - -**GAP 10 — [pending legal review]: counsel confirms the three readings this clause rests on: the 7-day -right reaches only direct sales and direct marketing, and not a natural person under the 1,800,000 baht -threshold who is not on the direct-marketing register; no regulation exempts digital services from it, so a consent-to-start -waiver has no Thai basis once it binds; and the refund is due within 15 days of the cancellation notice. -How the switch is flipped on the trigger date is not built.** + + ## Retention diff --git a/legal/README.md b/legal/README.md new file mode 100644 index 0000000..03744ea --- /dev/null +++ b/legal/README.md @@ -0,0 +1,47 @@ +# legal/: the legal-state record and the switch it drives + +This folder holds the state that decides whether a conditional legal clause is in force, and the clause text itself. +It is public, so it holds **state only**. + +| File | What it is | +|---|---| +| `legal-state.json` | The record: the seller type and the dates on which a legal trigger was met. Nothing else. | +| `thai-7day-clause.md` | The Thai 7-day cancellation clause (English and Thai), with a `{{since}}` placeholder for the date it takes effect. | + +`TERMS.md` holds one block between `` and ``. The block is empty +while no trigger has been met, and holds the dated clause once one has. It is never edited by hand: +`node scripts/legal-state.mjs --write` renders it from the record and the clause file, `node scripts/legal-state.mjs` (or the surface +check, rule 19) refuses a `TERMS.md` whose block disagrees with the record. + +## The rail: what never goes in this record + +A registration number, a tax identification number (for a sole trader, a personal national number), a revenue figure or any owner +identifier **never enters this repository**. The record names a fixed set of keys whose values are `null` or a calendar date; +`scripts/lib/legal-state.mjs` and rule 19 refuse any other key or any other kind of value. Where the design needs such a value (the +revenue measurement below needs the settled amounts), its home is the future private repository, and the public build reads only the +date it needs. Until that repository exists, no such value is stored anywhere in this build. + +## The triggers + +The clause takes effect on the first of three events. The record carries the date of each; the clause is in force when any stands. + +| Trigger | Who enters it | Record field | +|---|---|---| +| The seller is incorporated | the owner, as a dated commit | `incorporated.on` (and `sellerType` becomes `company`) | +| The seller registers as a direct-marketing business | the owner, as a dated commit | `directMarketingRegistered.on` | +| Annual revenue passes 1,800,000 baht | measured, then entered as a dated commit | `revenueThreshold.crossedOn` | + +## The revenue trigger: the design (nothing is built; no billing system exists yet) + +- **Measurement:** the payment provider's settled statements, net of refunds, in Thai baht. The definition of the year and of revenue is + the lawyer's reading, not ours. +- **Crossing:** the day the net total passes the threshold is `revenueThreshold.crossedOn`. The clause is armed from that date at once. +- **Veto window:** for 14 calendar days after the crossing, a measurement error may be vetoed (`revenueThreshold.vetoedOn`), and only + an error: the veto must fall inside the 14 days after `crossedOn`, which `scripts/lib/legal-state.mjs` checks. A vetoed crossing does + not arm the clause. +- **One-way latch:** once the window has passed without a veto, the crossing stands and is never cleared. +- **Audit log:** git history. Every entry is a dated commit, so who entered what and when is the record of the record. +- **What is not checked in a snapshot:** the latch and the timing of a veto cannot be seen in one file. The surface check accepts a veto + inside the window whenever it is committed; the history is what shows it was committed in time. + +The measuring job reads the amounts from the private side and writes only the date (or the veto) into this record. diff --git a/legal/legal-state.json b/legal/legal-state.json new file mode 100644 index 0000000..6d9eae5 --- /dev/null +++ b/legal/legal-state.json @@ -0,0 +1,7 @@ +{ + "schema": 1, + "sellerType": "natural-person", + "incorporated": { "on": null }, + "directMarketingRegistered": { "on": null }, + "revenueThreshold": { "crossedOn": null, "vetoedOn": null } +} diff --git a/legal/thai-7day-clause.md b/legal/thai-7day-clause.md new file mode 100644 index 0000000..d4d10a3 --- /dev/null +++ b/legal/thai-7day-clause.md @@ -0,0 +1,23 @@ +## Thai consumer cancellation right + +Under the Direct Sales and Direct Marketing Act B.E. 2545, a consumer who buys from a business that +sells directly or through direct marketing may cancel within **7 days** of receiving the service, and +then gets the **full price back**, paid out within **15 days** of the business receiving the +cancellation notice. This right binds Kolwen from {{since}}. + +A customer in Thailand cancels by notice through **support@kolwen.com**, and no consent-to-start +waiver reduces the refund inside the 7 days. Kolwen's protection against use followed by a refund is a +trial before payment, never a waiver of this right. + +> **[รอที่ปรึกษากฎหมาย] สิทธิยกเลิกของผู้บริโภคในประเทศไทย** +> ตามพระราชบัญญัติขายตรงและตลาดแบบตรง พ.ศ. 2545 ผู้บริโภคที่ซื้อจากผู้ประกอบธุรกิจที่ขายตรงหรือขายผ่านตลาดแบบตรง +> ยกเลิกได้ภายใน 7 วันนับแต่ได้รับบริการ และได้รับเงินคืนเต็มจำนวน โดยจ่ายคืนภายใน 15 วัน +> นับแต่ได้รับการบอกเลิก สิทธินี้ผูกพัน Kolwen ตั้งแต่ {{since}} ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่าน **support@kolwen.com** +> ไม่มีการสละสิทธิเพื่อลดเงินคืนในช่วง 7 วัน วิธีที่ Kolwen ป้องกันตนเองจากการใช้แล้วขอคืนเงินคือการให้ทดลองใช้ก่อนชำระเงิน +> ไม่ใช่การให้ลูกค้าสละสิทธิ + +**GAP 10 — [pending legal review]: counsel confirms the three readings this clause rests on: the 7-day +right reaches only direct sales and direct marketing, and not a natural person under the 1,800,000 baht +threshold who is not on the direct-marketing register; no regulation exempts digital services from it, so a consent-to-start +waiver has no Thai basis once it binds; and the refund is due within 15 days of the cancellation notice. +This clause appears in TERMS.md only while the legal-state record says a trigger has been met.** diff --git a/scripts/legal-state.mjs b/scripts/legal-state.mjs new file mode 100644 index 0000000..11534ad --- /dev/null +++ b/scripts/legal-state.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// legal-state: keeps the Thai 7-day clause in TERMS.md in step with legal/legal-state.json. +// Usage: node scripts/legal-state.mjs [--check | --write] -h / --help prints this and exits 0 +// --check (default) validates the record and compares the TERMS.md block with what the record requires. Exit 0 consistent, +// 1 a finding (printed, one per line), 2 bad input (a missing file, an unknown flag). +// --write renders the block into TERMS.md from the record and the clause file. This is the ONLY way to flip the switch +// after a trigger date is entered in the record; the block is never edited by hand. +// Paths are relative to the working directory (the repo root). The logic is in scripts/lib/legal-state.mjs. +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { STATE_FILE, CLAUSE_FILE, TERMS_FILE, findings, renderTerms, activeSince } from './lib/legal-state.mjs'; + +const HELP = 'usage: node scripts/legal-state.mjs [--check | --write]\n --check (default) refuse a TERMS.md whose 7-day clause block disagrees with legal/legal-state.json\n --write render the block into TERMS.md from the record\n example: edit legal/legal-state.json (a dated commit), then node scripts/legal-state.mjs --write\n'; +let mode = 'check'; +for (const a of process.argv.slice(2)) { + if (a === '-h' || a === '--help') { process.stdout.write(HELP); process.exit(0); } + if (a === '--check' || a === '--write') { mode = a.slice(2); continue; } + process.stderr.write(`legal-state: unknown argument ${JSON.stringify(a)}\n${HELP}`); + process.exit(2); +} +const read = f => (existsSync(f) ? readFileSync(f, 'utf8') : null); +const texts = { record: read(STATE_FILE), clause: read(CLAUSE_FILE), terms: read(TERMS_FILE) }; +for (const [name, text] of [[STATE_FILE, texts.record], [CLAUSE_FILE, texts.clause], [TERMS_FILE, texts.terms]]) { + if (text === null) { process.stderr.write(`legal-state: cannot read ${name}\n`); process.exit(2); } +} + +if (mode === 'write') { + const recordFindings = findings(texts).filter(f => f.kind === 'record' || f.kind === 'markers'); + if (recordFindings.length) { process.stdout.write(recordFindings.map(f => 'legal-state: ' + f.msg).join('\n') + '\n'); process.exit(1); } + const out = renderTerms(texts.terms, texts.clause, JSON.parse(texts.record)); + if (out.text === texts.terms.replace(/\r\n/g, '\n')) process.stdout.write('legal-state: TERMS.md is already current\n'); + else { writeFileSync(TERMS_FILE, out.text); process.stdout.write('legal-state: TERMS.md block rendered from the record\n'); } +} else { + const fs = findings(texts); + if (fs.length) { process.stdout.write(fs.map(f => 'legal-state: ' + f.msg).join('\n') + '\n'); process.exitCode = 1; } + else { + const since = activeSince(JSON.parse(texts.record)); + process.stdout.write(`legal-state: the record says the clause is ${since ? 'in force since ' + since : 'not in force'} and TERMS.md agrees\n`); + } +} diff --git a/scripts/legal-state.test.mjs b/scripts/legal-state.test.mjs new file mode 100644 index 0000000..c7d8579 --- /dev/null +++ b/scripts/legal-state.test.mjs @@ -0,0 +1,148 @@ +// Tests for scripts/legal-state.mjs: the Thai 7-day clause is in TERMS.md only while legal/legal-state.json says a trigger is met. +// Every fixture tree is built in a fresh temp folder and removed after. LEGAL_STATE_SCRIPT points the suite at another copy of the +// script (a stub that reports nothing, or a mutant): that is how the red-first run and the mutant table are made. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCRIPT = process.env.LEGAL_STATE_SCRIPT ? resolve(process.env.LEGAL_STATE_SCRIPT) : join(HERE, 'legal-state.mjs'); +const REAL = f => readFileSync(join(HERE, '..', f), 'utf8'); + +const BEGIN = '', END = ''; +const CLAUSE = '## Thai consumer cancellation right\n\nA consumer in Thailand may cancel within **7 days** and gets the full price back. Binds from {{since}}.\n\n> สิทธิยกเลิกภายใน 7 วัน ผูกพันตั้งแต่ {{since}}\n\n**GAP 10 — [pending legal review]: counsel confirms.**\n'; +const rec = o => JSON.stringify({ schema: 1, sellerType: 'natural-person', incorporated: { on: null }, directMarketingRegistered: { on: null }, revenueThreshold: { crossedOn: null, vetoedOn: null }, ...o }, null, 2) + '\n'; +const termsWith = block => `# Terms\n\nBefore.\n\n${block}\n\nAfter.\n`; +const EMPTY = BEGIN + '\n' + END; +const FULL = since => BEGIN + '\n\n' + CLAUSE.trim().split('{{since}}').join(since) + '\n\n' + END; + +function fixture(t, { record = rec({}), terms = termsWith(EMPTY), clause = CLAUSE } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'legal-state-test-')); + t.after(() => { rmSync(dir, { recursive: true, force: true }); }); + mkdirSync(join(dir, 'legal'), { recursive: true }); + writeFileSync(join(dir, 'legal/legal-state.json'), record); + writeFileSync(join(dir, 'legal/thai-7day-clause.md'), clause); + writeFileSync(join(dir, 'TERMS.md'), terms); + return dir; +} +const run = (dir, args = []) => spawnSync(process.execPath, ['--max-old-space-size=2048', SCRIPT, ...args], { cwd: dir, encoding: 'utf8', timeout: 30000 }); +const termsOf = dir => readFileSync(join(dir, 'TERMS.md'), 'utf8'); + +test('record inactive and the block empty: --check passes, and the clause is in neither language', t => { + const d = fixture(t); + const r = run(d); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /legal-state: the record says the clause is not in force and TERMS\.md agrees/, 'a pass must say what it checked'); + assert.doesNotMatch(termsOf(d), /7 days|7 วัน/); +}); + +test('record inactive but the clause is in TERMS.md: --check refuses (exit 1), naming the disagreement', t => { + const d = fixture(t, { terms: termsWith(FULL('2026-01-01')) }); + const r = run(d); + assert.equal(r.status, 1, r.stdout + r.stderr); + assert.match(r.stdout, /record says the clause is not in force/); +}); + +test('record active (incorporated): --write puts the clause in BOTH languages, dated; --check then passes; a second --write changes nothing', t => { + const d = fixture(t, { record: rec({ sellerType: 'company', incorporated: { on: '2026-12-01' } }) }); + const before = run(d); + assert.equal(before.status, 1, 'an empty block while the record is active must be refused: ' + before.stdout + before.stderr); + assert.match(before.stdout, /record says the clause is in force/); + const w = run(d, ['--write']); + assert.equal(w.status, 0, w.stdout + w.stderr); + const t1 = termsOf(d); + assert.match(t1, /\*\*7 days\*\*/, 'English clause'); + assert.match(t1, /7 วัน/, 'Thai clause'); + assert.match(t1, /Binds from 2026-12-01\./); + assert.match(t1, /ตั้งแต่ 2026-12-01/); + assert.equal(run(d).status, 0); + run(d, ['--write']); + assert.equal(termsOf(d), t1, '--write is idempotent'); +}); + +test('each trigger arms the clause on its own: direct-marketing registration, and a revenue crossing not vetoed; the earliest date is the one shown', t => { + const dm = fixture(t, { record: rec({ directMarketingRegistered: { on: '2027-03-02' } }) }); + assert.equal(run(dm, ['--write']).status, 0); + assert.match(termsOf(dm), /Binds from 2027-03-02\./); + const rev = fixture(t, { record: rec({ revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: null } }) }); + assert.equal(run(rev, ['--write']).status, 0); + assert.match(termsOf(rev), /Binds from 2027-05-10\./); + const both = fixture(t, { record: rec({ sellerType: 'company', incorporated: { on: '2027-06-01' }, revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: null } }) }); + assert.equal(run(both, ['--write']).status, 0); + assert.match(termsOf(both), /Binds from 2027-05-10\./); +}); + +test('a revenue crossing vetoed inside the 14-day window does not arm the clause; --write on an active-then-vetoed record empties the block', t => { + const d = fixture(t, { record: rec({ revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-20' } }), terms: termsWith(FULL('2027-05-10')) }); + assert.equal(run(d).status, 1); + assert.equal(run(d, ['--write']).status, 0); + assert.doesNotMatch(termsOf(d), /7 days/); + assert.equal(run(d).status, 0); +}); + +test('the record carries STATE ONLY: an unknown key, a registration-number-shaped value or a revenue figure is refused (exit 1)', t => { + const bad = [ + ['an unknown key', { registrationNumber: 'x' }], + ['a 13-digit value in a date field', { incorporated: { on: '9'.repeat(13) } }], + ['a revenue figure as a key', { revenueThreshold: { crossedOn: null, vetoedOn: null, amountThb: 1 } }], + ['a taxpayer-id key under a trigger', { directMarketingRegistered: { on: null, taxId: '8'.repeat(13) } }], + ['a non-date string', { incorporated: { on: 'soon' } }], + ['an impossible calendar date', { directMarketingRegistered: { on: '2027-02-30' } }], + ['an unknown seller type', { sellerType: 'partnership' }], + ]; + for (const [label, o] of bad) { + const d = fixture(t, { record: rec(o) }); + const r = run(d); + assert.equal(r.status, 1, label + ': ' + r.stdout + r.stderr); + assert.match(r.stdout, /legal-state\.json/, label); + } +}); + +test('the record must agree with itself: a company trigger needs sellerType company; a veto needs a crossing and a window of 14 calendar days', t => { + const bad = [ + ['incorporation entered while the seller is a natural person', { incorporated: { on: '2026-12-01' } }, /sellerType is not company/], + ['a veto with no crossing', { revenueThreshold: { crossedOn: null, vetoedOn: '2027-05-20' } }, /vetoedOn is set with no crossedOn/], + ['a veto after 14 calendar days', { revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-25' } }, /within 14 calendar days/], + ['a veto before the crossing', { revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-09' } }, /within 14 calendar days/], + ]; + for (const [label, o, why] of bad) { + const r = run(fixture(t, { record: rec(o) })); + assert.equal(r.status, 1, label + ': ' + r.stdout + r.stderr); + assert.match(r.stdout, why, label + ': refused for the right reason'); + } + const ok = run(fixture(t, { record: rec({ revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-24' } }) })); + assert.equal(ok.status, 0, 'a veto on day 14 is inside the window: ' + ok.stdout + ok.stderr); +}); + +test('TERMS.md without the marker pair, or with two pairs, is refused (the check is not vacuous)', t => { + const none = run(fixture(t, { terms: '# Terms\n\nNo block.\n' })); + assert.equal(none.status, 1, none.stdout + none.stderr); + assert.match(none.stdout, /marker/); + const twice = run(fixture(t, { terms: termsWith(EMPTY) + '\n' + EMPTY + '\n' })); + assert.equal(twice.status, 1, twice.stdout + twice.stderr); +}); + +test('a missing record, clause file or TERMS.md is bad input (exit 2), named; an unknown flag exits 2; --help exits 0', t => { + const d = fixture(t); + rmSync(join(d, 'legal/legal-state.json')); + const r = run(d); + assert.equal(r.status, 2, r.stdout + r.stderr); + assert.match(r.stderr, /legal-state\.json/); + assert.equal(run(fixture(t), ['--frobnicate']).status, 2); + assert.equal(run(fixture(t), ['--help']).status, 0); +}); + +test('the committed tree is consistent: the real record is inactive, the real TERMS.md block is empty, and the real clause file carries both languages', () => { + const state = JSON.parse(REAL('legal/legal-state.json')); + assert.equal(state.incorporated.on, null); + const terms = REAL('TERMS.md').replace(/\r\n/g, '\n'); + assert.ok(terms.includes(EMPTY), 'TERMS.md holds the empty block while the record is inactive'); + const clause = REAL('legal/thai-7day-clause.md'); + assert.match(clause, /\*\*7 days\*\*/); + assert.match(clause, /7 วัน/); + assert.match(clause, /\{\{since\}\}/); +}); diff --git a/scripts/lib/legal-state.mjs b/scripts/lib/legal-state.mjs new file mode 100644 index 0000000..0266f97 --- /dev/null +++ b/scripts/lib/legal-state.mjs @@ -0,0 +1,100 @@ +// The legal-state record and the switch it drives (a library for scripts/legal-state.mjs and scripts/surface-check.mjs rule 19). +// +// legal/legal-state.json holds STATE ONLY: the seller type and the DATES on which a legal trigger was met. The Thai 7-day +// cancellation clause is in force exactly when a trigger date is set (and, for revenue, not vetoed). The clause TEXT lives in +// legal/thai-7day-clause.md; TERMS.md carries it between one pair of markers only while the record says it is in force, and +// the block is a pure function of (record, clause file): nobody edits it by hand, `scripts/legal-state.mjs --write` renders it +// and surface-check rule 19 refuses a TERMS.md whose block disagrees with the record. +// +// THE RAIL ON THIS PUBLIC REPO: the record names a fixed set of keys whose values are null or a calendar date, and nothing else. +// A registration number, a tax identification number, a revenue figure or any owner identifier cannot be stored here; the +// design (legal/README.md) names the future private repository as their home, and this public build reads only a date. +export const STATE_FILE = 'legal/legal-state.json'; +export const CLAUSE_FILE = 'legal/thai-7day-clause.md'; +export const TERMS_FILE = 'TERMS.md'; +export const BEGIN = ''; +export const END = ''; +export const VETO_DAYS = 14; + +const SELLER_TYPES = ['natural-person', 'company']; +const SHAPE = { incorporated: ['on'], directMarketingRegistered: ['on'], revenueThreshold: ['crossedOn', 'vetoedOn'] }; +const TOP_KEYS = ['schema', 'sellerType', ...Object.keys(SHAPE)]; + +const isDate = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v; +const dayNumber = v => Date.parse(v + 'T00:00:00Z') / (24 * 3600 * 1000); +const sameKeys = (o, keys) => o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).sort().join() === [...keys].sort().join(); + +// Every reason the record is not a valid state record; an empty list means valid. +export function validateState(rec) { + const errs = []; + if (!sameKeys(rec, TOP_KEYS)) { errs.push(`the record must hold exactly the keys ${TOP_KEYS.join(', ')} (it holds state only: no number, no figure, no identifier)`); return errs; } + if (rec.schema !== 1) errs.push('schema must be 1'); + if (!SELLER_TYPES.includes(rec.sellerType)) errs.push(`sellerType must be one of ${SELLER_TYPES.join(', ')}`); + for (const [group, keys] of Object.entries(SHAPE)) { + if (!sameKeys(rec[group], keys)) { errs.push(`${group} must hold exactly the keys ${keys.join(', ')}`); continue; } + for (const k of keys) if (rec[group][k] !== null && !isDate(rec[group][k])) errs.push(`${group}.${k} must be null or a real calendar date (YYYY-MM-DD), nothing else`); + } + if (errs.length) return errs; + if (rec.incorporated.on !== null && rec.sellerType !== 'company') errs.push('incorporated.on is set but sellerType is not company'); + const { crossedOn, vetoedOn } = rec.revenueThreshold; + if (vetoedOn !== null) { + if (crossedOn === null) errs.push('revenueThreshold.vetoedOn is set with no crossedOn'); + else if (dayNumber(vetoedOn) < dayNumber(crossedOn) || dayNumber(vetoedOn) - dayNumber(crossedOn) > VETO_DAYS) errs.push(`revenueThreshold.vetoedOn must fall within ${VETO_DAYS} calendar days after crossedOn (a veto is for a measurement error only)`); + } + return errs; +} + +// The date the clause takes effect: the earliest trigger that stands (a vetoed revenue crossing does not), or null. +export function activeSince(rec) { + const dates = [rec.incorporated.on, rec.directMarketingRegistered.on, rec.revenueThreshold.vetoedOn === null ? rec.revenueThreshold.crossedOn : null].filter(Boolean); + return dates.length ? dates.sort()[0] : null; +} +export const clauseActive = rec => activeSince(rec) !== null; + +const lf = s => s.replace(/\r\n/g, '\n'); + +// The block exactly as TERMS.md must hold it: the two markers alone when the clause is not in force, the dated clause between them when it is. +export function renderBlock(clauseText, rec) { + const since = activeSince(rec); + if (since === null) return BEGIN + '\n' + END; + return BEGIN + '\n\n' + lf(clauseText).trim().split('{{since}}').join(since) + '\n\n' + END; +} + +// Where the block sits in TERMS.md: { error } unless there is exactly one begin marker, one end marker, begin before end. +export function locateBlock(terms) { + const t = lf(terms); + const count = (s, x) => s.split(x).length - 1; + if (count(t, BEGIN) !== 1 || count(t, END) !== 1) return { error: `TERMS.md must hold exactly one ${BEGIN} and one ${END} marker (found ${count(t, BEGIN)} and ${count(t, END)})` }; + const start = t.indexOf(BEGIN), end = t.indexOf(END) + END.length; + if (end - END.length < start) return { error: 'the end marker comes before the begin marker in TERMS.md' }; + return { start, end, text: t }; +} + +// TERMS.md with its block rendered from the record. +export function renderTerms(terms, clauseText, rec) { + const loc = locateBlock(terms); + if (loc.error) return { error: loc.error }; + return { text: loc.text.slice(0, loc.start) + renderBlock(clauseText, rec) + loc.text.slice(loc.end) }; +} + +// Findings for rule 19 and the CLI: [{ kind: 'record' | 'markers' | 'sync', msg }] over the three file TEXTS (null = file missing). +export function findings({ record, clause, terms }) { + const out = []; + for (const [name, text] of [[STATE_FILE, record], [CLAUSE_FILE, clause], [TERMS_FILE, terms]]) if (text === null) out.push({ kind: 'record', msg: `${name} is missing, so the legal-state check has nothing to hold` }); + if (out.length) return out; + let rec = null; + try { rec = JSON.parse(record.replace(/^\uFEFF/, '')); } catch { out.push({ kind: 'record', msg: `${STATE_FILE} is not valid JSON` }); return out; } + const errs = validateState(rec); + for (const e of errs) out.push({ kind: 'record', msg: `${STATE_FILE}: ${e}` }); + if (errs.length) return out; + const loc = locateBlock(terms); + if (loc.error) { out.push({ kind: 'markers', msg: loc.error }); return out; } + const want = renderBlock(clause, rec), have = loc.text.slice(loc.start, loc.end); + if (have !== want) { + const since = activeSince(rec); + out.push({ kind: 'sync', msg: since === null + ? 'the record says the clause is not in force, but the TERMS.md block is not empty (the clause must not render until a trigger is entered); run scripts/legal-state.mjs --write' + : `the record says the clause is in force since ${since}, but the TERMS.md block is not the rendered clause (run scripts/legal-state.mjs --write)` }); + } + return out; +} From af9cbca79551366981638b4fb7de4d522ca41d5b Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:40:17 +0700 Subject: [PATCH 4/9] feat(surface-check): rule 19 holds the legal-state record against TERMS.md The surface check now reads legal/legal-state.json, legal/thai-7day-clause.md and TERMS.md through the same library as the switch script and refuses: a record of the wrong shape (an unknown key, a value that is not null or a real date, a company trigger on a natural person, a veto outside its 14 days) or a missing file; a TERMS.md without exactly one pair of markers; and a block that is not the rendering of the record and the clause file (a clause showing while no trigger is met, or missing once one is). Rule 10 now also reads the clause file, so its counsel-pending gap keeps its marker. The stated limits (the one-way latch and the veto timing need git history, the trigger date is the owner's commit, revenue has no meter yet) are in the rule's comment. Red first on a throwaway copy: against the gate without rule 19, all 12 refusal fixtures passed; with it all are refused, the two no-false-refusal rows (an active record with a rendered block, a vetoed crossing with an empty block) pass, and each of the three clauses removed lets its own fixture through. Co-Authored-By: Claude Sonnet 5.5 --- scripts/surface-check.mjs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/surface-check.mjs b/scripts/surface-check.mjs index db62384..ae3ca0f 100644 --- a/scripts/surface-check.mjs +++ b/scripts/surface-check.mjs @@ -244,7 +244,7 @@ if (existsSync('web/ic.json')) { // merely describes what the marker means (a draft header) is not a gap and is never counted as one. // The Thai marker is deliberately NOT asserted per gap (the Thai rides as short blockquotes, not a // parallel legal text); it is only counted wrap-aware and reported. -const LEGAL_DRAFTS = ['PRIVACY.md', 'TERMS.md']; +const LEGAL_DRAFTS = ['PRIVACY.md', 'TERMS.md', 'legal/thai-7day-clause.md']; const gapNotes = []; for (const doc of LEGAL_DRAFTS) { if (!existsSync(doc)) { @@ -854,6 +854,40 @@ const lineOf = (s, i) => s.slice(0, i).split('\n').length; } else if (tracked.includes(REDIRECTS)) note(REDIRECTS, 'is tracked but cannot be read, so rule 18 checked nothing'); } +// ── 19. The Thai 7-day clause in TERMS.md agrees with the legal-state record ── +// The clause is in TERMS.md only while legal/legal-state.json says a legal trigger has been met (incorporation, direct-marketing +// registration, or a revenue crossing that stands); the block between the two legal-state markers is a pure function of the +// record and legal/thai-7day-clause.md, rendered by scripts/legal-state.mjs --write and never edited by hand. A clause that shows +// while the record says it is not in force, or is missing once it is, would put a false legal statement on a public page. +// One clause per marker, so a mutant can remove one at a time: +// L19_RECORD the record is valid JSON of the fixed shape: exact keys, values null or a real date (a registration number, a tax +// identification number, a revenue figure or any owner identifier CANNOT be stored there), a company trigger needs a +// company seller, a veto sits inside 14 calendar days of its crossing; a missing file is a finding +// L19_MARKERS TERMS.md holds exactly one begin and one end marker +// L19_SYNC the block equals the rendering of (record, clause file) +// STATED LIMITS: the one-way latch and the 14-day veto TIMING cannot be seen in one snapshot (git history is the audit log; a +// veto inside the window is accepted whenever it is committed); that the trigger date is true is the owner's dated commit, not +// this rule's; revenue is measured by a billing system that does not exist yet (legal/README.md holds the design); the rule reads +// the English and Thai clause because the clause file carries both, and holds nothing about their legal wording. +{ + let ls = null; + try { ls = await import(pathToFileURL(resolve('scripts/lib/legal-state.mjs')).href); } + catch (e) { note('scripts/lib/legal-state.mjs', 'could not be loaded, so rule 19 could not read the legal-state record (' + (e.code || 'import failed') + ')'); } + if (ls) { + const text = p => (existsSync(p) ? read(p) : null); + const fs19 = ls.findings({ record: text(ls.STATE_FILE), clause: text(ls.CLAUSE_FILE), terms: text(ls.TERMS_FILE) }); + /* L19_RECORD:begin */ + for (const x of fs19.filter(x => x.kind === 'record')) note(ls.STATE_FILE, x.msg); + /* L19_RECORD:end */ + /* L19_MARKERS:begin */ + for (const x of fs19.filter(x => x.kind === 'markers')) note(ls.TERMS_FILE, x.msg); + /* L19_MARKERS:end */ + /* L19_SYNC:begin */ + for (const x of fs19.filter(x => x.kind === 'sync')) note(ls.TERMS_FILE, x.msg); + /* L19_SYNC:end */ + } +} + if (fail.length) { console.error('surface check FAILED:\n' + fail.map(f => ' - ' + f).join('\n')); process.exit(1); From 27e0017554128706b1783914fca7d69dd71714a3 Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:07:07 +0700 Subject: [PATCH 5/9] fix(legal-state): a company seller with no incorporation date is refused The record validator already refused an incorporation date on a natural person; the reverse was accepted. A company with incorporated.on unset was a valid record, so the incorporation trigger never armed the clause and nothing reported it. The validator now refuses it, and the "record must agree with itself" test holds the case (red against the old validator, killed by a mutant that removes the check). Co-Authored-By: Claude Sonnet 5.5 --- scripts/legal-state.test.mjs | 1 + scripts/lib/legal-state.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/legal-state.test.mjs b/scripts/legal-state.test.mjs index c7d8579..b4976b3 100644 --- a/scripts/legal-state.test.mjs +++ b/scripts/legal-state.test.mjs @@ -105,6 +105,7 @@ test('the record carries STATE ONLY: an unknown key, a registration-number-shape test('the record must agree with itself: a company trigger needs sellerType company; a veto needs a crossing and a window of 14 calendar days', t => { const bad = [ ['incorporation entered while the seller is a natural person', { incorporated: { on: '2026-12-01' } }, /sellerType is not company/], + ['a company seller with no incorporation date (the trigger would never arm)', { sellerType: 'company' }, /sellerType is company but incorporated[.]on is not set/], ['a veto with no crossing', { revenueThreshold: { crossedOn: null, vetoedOn: '2027-05-20' } }, /vetoedOn is set with no crossedOn/], ['a veto after 14 calendar days', { revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-25' } }, /within 14 calendar days/], ['a veto before the crossing', { revenueThreshold: { crossedOn: '2027-05-10', vetoedOn: '2027-05-09' } }, /within 14 calendar days/], diff --git a/scripts/lib/legal-state.mjs b/scripts/lib/legal-state.mjs index 0266f97..afc539c 100644 --- a/scripts/lib/legal-state.mjs +++ b/scripts/lib/legal-state.mjs @@ -36,6 +36,7 @@ export function validateState(rec) { } if (errs.length) return errs; if (rec.incorporated.on !== null && rec.sellerType !== 'company') errs.push('incorporated.on is set but sellerType is not company'); + if (rec.sellerType === 'company' && rec.incorporated.on === null) errs.push('sellerType is company but incorporated.on is not set (the incorporation trigger would never arm the clause)'); const { crossedOn, vetoedOn } = rec.revenueThreshold; if (vetoedOn !== null) { if (crossedOn === null) errs.push('revenueThreshold.vetoedOn is set with no crossedOn'); From 4d8fe27867061fc15c1872146afb50516921db1d Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:15:39 +0700 Subject: [PATCH 6/9] docs: record the legal-state switch, mirror the chargeback track in Thai, and tighten the 7-day clause wording The CHANGELOG now says the Thai 7-day clause lives in legal/ and appears in TERMS.md only when the legal-state record says a trigger has been met, and has a bullet for the record, the clause file, the marked block, the script and rule 19. The Thai enforcement section gains the lead-in that the list of prohibited conduct is not written yet and the chargeback track, so it says what the English says. policies.md names rule 19 and CONTRIBUTING lists the new test and the script that flips the clause. In the clause itself, the trial before payment is stated as an offer to make rather than a present fact, and the Thai says whose receipt of the cancellation notice starts the 15 days. It stays under the pending-legal- review marker. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 19 +++++++++++++++---- CONTRIBUTING.md | 5 +++-- TERMS.md | 7 +++++++ governance/policies.md | 2 +- legal/thai-7day-clause.md | 6 +++--- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401b62e..11ccec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,12 +158,22 @@ the pointer to it. ### Added +- `legal/`: the switch for the Thai 7-day cancellation clause. `legal/legal-state.json` records the seller type and the + dates on which a legal trigger was met (incorporation, direct-marketing registration, or annual revenue above + 1,800,000 baht), and only that: its keys and values are fixed to null or a calendar date, so a registration number, + a tax identification number, a revenue figure or any owner identifier cannot be stored in this public repository. + The clause text (English and Thai) is `legal/thai-7day-clause.md`. `TERMS.md` holds one marked block that is empty + while no trigger has been met and holds the dated clause once one has; `node scripts/legal-state.mjs --write` renders + it and is the only way to flip the clause, and `node scripts/legal-state.mjs` (or rule 19 of the surface check) + refuses a `TERMS.md` that disagrees with the record. Its tests run in CI. The revenue trigger is written as a design + in `legal/README.md`; no billing system measures it yet. The record ships inactive, so the clause is absent from + `TERMS.md` today. - The enforcement process in `TERMS.md` now has its numbers, in English and in a new Thai version: 14 business days to answer a notice, a written decision 7 business days after the answer, 30 business days to file the one appeal through the appeals role address, an answer within 14 business days, the key's clock stopped for the whole case, a ceiling of 90 calendar days from the notice, and a missed deadline on Kolwen's side lifting the suspension. A business day is a normal - working day in Thailand, Monday to Friday, without the public holidays announced in the Royal Gazette. The dormant Thai - 7-day clause names the support role address as its cancellation channel. All of it stays under the pending-legal-review + working day in Thailand, Monday to Friday, without the public holidays announced in the Royal Gazette. The Thai + 7-day clause (kept in `legal/`, see below) names the support role address as its cancellation channel. All of it stays under the pending-legal-review marker and unlinked. - The open-weights plan is now written in the future tense in `README.md`, `LICENSE`, `py/LICENSE` and `SECURITY.md`: planned, not released, no release date set, nothing on sale, each release to carry its own license, and no claim about @@ -196,8 +206,9 @@ the pointer to it. - `TERMS.md` also carries the enforcement process for a customer's fault as due process (notice, suspension rather than termination, a window to answer, a written decision, one appeal to a human, a register of cases; a chargeback suspends and is restored or terminated by how the dispute closes, on Paddle's track). The Thai 7-day cancellation right is - written in both languages as a dormant clause that takes effect on incorporation, direct-marketing registration or - revenue above 1,800,000 baht a year; until then the refunds above are the live rule. Nothing flips the switch yet. + written in both languages in `legal/thai-7day-clause.md`, outside `TERMS.md`; it appears in `TERMS.md` only when + `legal/legal-state.json` says a trigger has been met (see the `legal/` bullet below). Until then the refunds above are + the live rule. - `PRIVACY.md` names Cloudflare Web Analytics (aggregate statistics only: page, referrer, country, browser; no one identified; counts run low behind an ad-blocker) and adds a short Age section. Its sentence about an injected bot-detection script is replaced, because the zone's JavaScript Detections were switched off on 2026-10-07 and a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46fa0b0..1e67000 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,12 +38,13 @@ nothing here needs an `npm install` to run. ```bash node scripts/surface-check.mjs # the room's own laws: claims, leakage, orthography, contrast, the tracker claim, production noindex node scripts/secret-gate.mjs # the house secret scan over the tracked tree (the hooks also scan what a commit or push adds) -node --test scripts/secret-scan.test.mjs scripts/secret-gate.test.mjs # the scanner's and the gate's own tests +node --test scripts/secret-scan.test.mjs scripts/secret-gate.test.mjs scripts/legal-state.test.mjs # the scanner's, the gate's and the legal-state switch's own tests +node scripts/legal-state.mjs # check TERMS.md against legal/legal-state.json; --write renders it, and is the only way to flip the Thai 7-day clause node brand/make-brand.mjs # regenerate every brand asset; must leave git clean node scripts/post-deploy-check.mjs # compare the live site to what is committed (needs network); --origin checks one host ``` -The surface check, the brand byte-identity check and the secret gate (with its two test files) run in CI on every push and must pass. The post-deploy check runs after a deploy. +The surface check, the brand byte-identity check and the secret gate (with its test files) run in CI on every push and must pass. The post-deploy check runs after a deploy. The repository also ships a `pre-commit` and a `pre-push` hook in `.githooks/` that run the secret scan, then the surface check. `pre-commit` scans the working-tree copy of each tracked file, not what is staged, so a key that is staged and then removed from the working copy passes it; `pre-push` scans the added lines, commit messages and tags of every pushed commit, and CI scans the tree, so that key is caught there. They run only in a clone that ran `git config core.hooksPath .githooks`, and `git push --no-verify` bypasses them; GitHub's server-side secret scanning and push protection stay this repository's other wall. diff --git a/TERMS.md b/TERMS.md index 11d38a6..b6fd532 100644 --- a/TERMS.md +++ b/TERMS.md @@ -177,6 +177,8 @@ money is gone. It is **restored** if the dispute resolves in Kolwen's favour or directly. > **[รอที่ปรึกษากฎหมาย] ขั้นตอนเมื่อ Kolwen เห็นว่าลูกค้าฝ่าฝืนกฎ** +> รายการพฤติกรรมที่ห้ามยังไม่ได้เขียน เป็นเรื่องที่เจ้าของและที่ปรึกษากฎหมายต้องตัดสิน และหัวข้อนี้ไม่เดาแทน +> สิ่งที่เขียนไว้คือวิธีที่ Kolwen ปฏิบัติเมื่อเห็นว่าลูกค้าฝ่าฝืนกฎ ตามลำดับนี้ > 1. แจ้งข้อกำหนดที่เห็นว่าฝ่าฝืน พร้อมสรุปพยานหลักฐาน > 2. ระงับบัญชี ไม่ใช่เลิกสัญญา ระหว่างที่เคสยังเปิด ถ้าสินค้าเป็นคีย์ นาฬิกานับวันของคีย์หยุดตลอดทั้งเคส > 3. ลูกค้ามีเวลา **14 วันทำการ** เพื่อตอบข้อแจ้ง @@ -192,6 +194,11 @@ directly. > **เพดานและผลของการพลาดกำหนด** เคสหนึ่งยาวไม่เกิน **90 วันตามปฏิทิน** นับจากวันที่แจ้ง หาก Kolwen พลาดกำหนดของตนเอง > (7 วันทำการสำหรับคำตัดสิน 14 วันทำการสำหรับตอบอุทธรณ์ หรือเพดาน 90 วัน) การระงับจะหลุดและคีย์กลับมาใช้ได้ หากกำหนดของลูกค้าหมดลง > (14 วันทำการสำหรับตอบ หรือ 30 วันทำการสำหรับอุทธรณ์) เคสเดินต่อไปยังขั้นถัดไป +> +> **การเรียกเงินคืนผ่านธนาคาร (chargeback)** ดำเนินตามขั้นตอนของ Paddle ข้อพิพาทกับธนาคารจัดการผ่าน Paddle ในฐานะผู้ขายตามกฎหมาย +> (merchant of record) และ Paddle ขอหลักฐานจาก Kolwen บัญชีจะถูกระงับเมื่อมีการยื่น chargeback เพราะเงินหลุดไปแล้ว +> จะคืนสถานะหากข้อพิพาทจบเป็นคุณแก่ Kolwen หรือลูกค้าชำระใหม่ และจะเลิกสัญญาก็ต่อเมื่อข้อพิพาทจบเป็นผลร้ายต่อ Kolwen เท่านั้น +> Kolwen ไม่โต้แย้ง chargeback กับลูกค้าโดยตรง **GAP 9 — [pending legal review]: counsel confirms the notice duties before suspension or termination (consumer law favours notice first), the definition of a business day against the Civil and Commercial diff --git a/governance/policies.md b/governance/policies.md index 1c28377..1795f10 100644 --- a/governance/policies.md +++ b/governance/policies.md @@ -21,7 +21,7 @@ retyped copy of the IC label, or an IC-label key that `web/ic.json` should declare as required text and leaves empty or undeclared; a Content-Security-Policy that no longer admits the inline script and styles the pages carry, or that has lost its floor (the `form-action` directive included) or the two cross-origin - headers, or whose `script-src` lacks the one exact Web Analytics beacon source or carries any other `cloudflareinsights` source (it does not refuse other `script-src` sources); any HTML page under `web/` (the landing page, the 404 page, a preview page, or one added later) that says "no trackers" (English or Thai) while the site runs Cloudflare Web Analytics; a robots `noindex` meta on a production page (the 404 page's own is a named exemption), or an `X-Robots-Tag` noindex on any `web/_headers` rule that is not an absolute `*.workers.dev` host; a page under `web/` that names a Free plan or a free tier (English or Thai); on a production page, a live buy control (a button that is not disabled, a form, a buy link), a Paddle script or a checkout URL; a line in `web/_redirects` that is malformed, whose same-site destination is no shipped page (or is a control file the edge never serves), hides a shipped page, chains to another redirect, or redirects a URL the sitemap lists (a destination on another site is not checked); and a legal draft (`PRIVACY.md`, `TERMS.md`) whose + headers, or whose `script-src` lacks the one exact Web Analytics beacon source or carries any other `cloudflareinsights` source (it does not refuse other `script-src` sources); any HTML page under `web/` (the landing page, the 404 page, a preview page, or one added later) that says "no trackers" (English or Thai) while the site runs Cloudflare Web Analytics; a robots `noindex` meta on a production page (the 404 page's own is a named exemption), or an `X-Robots-Tag` noindex on any `web/_headers` rule that is not an absolute `*.workers.dev` host; a page under `web/` that names a Free plan or a free tier (English or Thai); on a production page, a live buy control (a button that is not disabled, a form, a buy link), a Paddle script or a checkout URL; a line in `web/_redirects` that is malformed, whose same-site destination is no shipped page (or is a control file the edge never serves), hides a shipped page, chains to another redirect, or redirects a URL the sitemap lists (a destination on another site is not checked); a legal-state record (`legal/legal-state.json`) that carries any key or value beyond the fixed state-only shapes, a `TERMS.md` without exactly one marked Thai 7-day block, or a block that disagrees with what the record renders (rule 19); and a legal draft (`PRIVACY.md`, `TERMS.md`) whose labelled gap has lost the marker saying counsel has not cleared it. **Every claim outside the script's rules—what a document says a feature does, for one—is held by review, not by a checker.** The rule is not softened to fit the machine; the gap is stated. diff --git a/legal/thai-7day-clause.md b/legal/thai-7day-clause.md index d4d10a3..ae07b2f 100644 --- a/legal/thai-7day-clause.md +++ b/legal/thai-7day-clause.md @@ -6,14 +6,14 @@ then gets the **full price back**, paid out within **15 days** of the business r cancellation notice. This right binds Kolwen from {{since}}. A customer in Thailand cancels by notice through **support@kolwen.com**, and no consent-to-start -waiver reduces the refund inside the 7 days. Kolwen's protection against use followed by a refund is a +waiver reduces the refund inside the 7 days. Kolwen's protection against use followed by a refund is to offer a trial before payment, never a waiver of this right. > **[รอที่ปรึกษากฎหมาย] สิทธิยกเลิกของผู้บริโภคในประเทศไทย** > ตามพระราชบัญญัติขายตรงและตลาดแบบตรง พ.ศ. 2545 ผู้บริโภคที่ซื้อจากผู้ประกอบธุรกิจที่ขายตรงหรือขายผ่านตลาดแบบตรง > ยกเลิกได้ภายใน 7 วันนับแต่ได้รับบริการ และได้รับเงินคืนเต็มจำนวน โดยจ่ายคืนภายใน 15 วัน -> นับแต่ได้รับการบอกเลิก สิทธินี้ผูกพัน Kolwen ตั้งแต่ {{since}} ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่าน **support@kolwen.com** -> ไม่มีการสละสิทธิเพื่อลดเงินคืนในช่วง 7 วัน วิธีที่ Kolwen ป้องกันตนเองจากการใช้แล้วขอคืนเงินคือการให้ทดลองใช้ก่อนชำระเงิน +> นับแต่ Kolwen ได้รับการบอกเลิก สิทธินี้ผูกพัน Kolwen ตั้งแต่ {{since}} ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่าน **support@kolwen.com** +> ไม่มีการสละสิทธิเพื่อลดเงินคืนในช่วง 7 วัน วิธีที่ Kolwen ป้องกันตนเองจากการใช้แล้วขอคืนเงินคือการเสนอให้ทดลองใช้ก่อนชำระเงิน > ไม่ใช่การให้ลูกค้าสละสิทธิ **GAP 10 — [pending legal review]: counsel confirms the three readings this clause rests on: the 7-day From 8950a2e60a3543696161650e3ba0c4a558584743 Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:06:49 +0700 Subject: [PATCH 7/9] docs(surface-check): declare why the check stays one file over the 800-line signal The file is 895 lines, over the room's 800-line review signal. Its head comment now carries the declared reason: every rule reads one tracked-file list, one set of scope predicates and one failure list, and the file is the list of record that docs/TRUST.md and governance/policies.md name, so a split would leave two lists of record. No code change. Co-Authored-By: Claude Sonnet 5.5 --- scripts/surface-check.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/surface-check.mjs b/scripts/surface-check.mjs index ae3ca0f..bafb40c 100644 --- a/scripts/surface-check.mjs +++ b/scripts/surface-check.mjs @@ -3,6 +3,10 @@ // This repo's risk is not broken code: one zero-dependency generator is the only executable of // consequence. Its risk is a FALSE PUBLIC CLAIM. Every assertion below is a rule the room // already holds and has already caught a violation of. Zero dependencies, Node built-ins only. +// +// ponytail: 895 lines at declaration — every rule reads one tracked-file list (`git ls-files`), one set of +// scope predicates and one failure list, and this file is the list of record that docs/TRUST.md and +// governance/policies.md point at ("its numbered rules are the list"); a split would give two lists of record. import { readFileSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; From 9e60a5531df7e92bccff54f56dc43e8a428aa4bd Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:17:23 +0700 Subject: [PATCH 8/9] docs(surface-check): plain wording for the declared reason (review N1, N2) The declared reason for the 895-line file now reads as plain prose: no session-mode label and no pinned line count. It claims only what the file shows: one failure list, scope predicates defined once, and the numbered rules as the list of record named by docs/TRUST.md and governance/policies.md. Comment-only. Co-Authored-By: Claude Sonnet 5.5 --- scripts/surface-check.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/surface-check.mjs b/scripts/surface-check.mjs index bafb40c..fdf5883 100644 --- a/scripts/surface-check.mjs +++ b/scripts/surface-check.mjs @@ -4,9 +4,10 @@ // consequence. Its risk is a FALSE PUBLIC CLAIM. Every assertion below is a rule the room // already holds and has already caught a violation of. Zero dependencies, Node built-ins only. // -// ponytail: 895 lines at declaration — every rule reads one tracked-file list (`git ls-files`), one set of -// scope predicates and one failure list, and this file is the list of record that docs/TRUST.md and -// governance/policies.md point at ("its numbered rules are the list"); a split would give two lists of record. +// Declared reason (over the 800-line review signal): every rule reports through one failure list, +// the scope predicates the rules use are defined once here, and the numbered rules in this file are +// the list of record that docs/TRUST.md and governance/policies.md point at. Helpers can move to +// scripts/lib/; the rules stay in one entry script, so there is one list and not two. import { readFileSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; From 442285b9f95e41f965805d3d3dc2d2ff9f77141f Mon Sep 17 00:00:00 2001 From: HetCreep <239326951+HetCreep@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:33:12 +0700 Subject: [PATCH 9/9] fix(legal-state): exit codes without process.exit, one BOM-stripped parse, and rule 10's list in policies (review) Three review threads on the legal-state work. - scripts/legal-state.mjs runs in main() and sets process.exitCode instead of calling process.exit; the exit codes are unchanged (0, 1, 2). - A leading byte-order mark on the record is stripped once at read, so validation and parsing see the same text. Before, a record that passed validation threw an uncaught SyntaxError in --write. The character is built from its code point. One test covers a BOM-led active record. - governance/policies.md rule 10 now lists legal/thai-7day-clause.md among the legal drafts, as the check runs it. Co-Authored-By: Claude Sonnet 5.5 --- governance/policies.md | 2 +- scripts/legal-state.mjs | 13 +++++++++---- scripts/legal-state.test.mjs | 10 ++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/governance/policies.md b/governance/policies.md index 1795f10..7f675a0 100644 --- a/governance/policies.md +++ b/governance/policies.md @@ -21,7 +21,7 @@ retyped copy of the IC label, or an IC-label key that `web/ic.json` should declare as required text and leaves empty or undeclared; a Content-Security-Policy that no longer admits the inline script and styles the pages carry, or that has lost its floor (the `form-action` directive included) or the two cross-origin - headers, or whose `script-src` lacks the one exact Web Analytics beacon source or carries any other `cloudflareinsights` source (it does not refuse other `script-src` sources); any HTML page under `web/` (the landing page, the 404 page, a preview page, or one added later) that says "no trackers" (English or Thai) while the site runs Cloudflare Web Analytics; a robots `noindex` meta on a production page (the 404 page's own is a named exemption), or an `X-Robots-Tag` noindex on any `web/_headers` rule that is not an absolute `*.workers.dev` host; a page under `web/` that names a Free plan or a free tier (English or Thai); on a production page, a live buy control (a button that is not disabled, a form, a buy link), a Paddle script or a checkout URL; a line in `web/_redirects` that is malformed, whose same-site destination is no shipped page (or is a control file the edge never serves), hides a shipped page, chains to another redirect, or redirects a URL the sitemap lists (a destination on another site is not checked); a legal-state record (`legal/legal-state.json`) that carries any key or value beyond the fixed state-only shapes, a `TERMS.md` without exactly one marked Thai 7-day block, or a block that disagrees with what the record renders (rule 19); and a legal draft (`PRIVACY.md`, `TERMS.md`) whose + headers, or whose `script-src` lacks the one exact Web Analytics beacon source or carries any other `cloudflareinsights` source (it does not refuse other `script-src` sources); any HTML page under `web/` (the landing page, the 404 page, a preview page, or one added later) that says "no trackers" (English or Thai) while the site runs Cloudflare Web Analytics; a robots `noindex` meta on a production page (the 404 page's own is a named exemption), or an `X-Robots-Tag` noindex on any `web/_headers` rule that is not an absolute `*.workers.dev` host; a page under `web/` that names a Free plan or a free tier (English or Thai); on a production page, a live buy control (a button that is not disabled, a form, a buy link), a Paddle script or a checkout URL; a line in `web/_redirects` that is malformed, whose same-site destination is no shipped page (or is a control file the edge never serves), hides a shipped page, chains to another redirect, or redirects a URL the sitemap lists (a destination on another site is not checked); a legal-state record (`legal/legal-state.json`) that carries any key or value beyond the fixed state-only shapes, a `TERMS.md` without exactly one marked Thai 7-day block, or a block that disagrees with what the record renders (rule 19); and a legal draft (`PRIVACY.md`, `TERMS.md`, `legal/thai-7day-clause.md`) whose labelled gap has lost the marker saying counsel has not cleared it. **Every claim outside the script's rules—what a document says a feature does, for one—is held by review, not by a checker.** The rule is not softened to fit the machine; the gap is stated. diff --git a/scripts/legal-state.mjs b/scripts/legal-state.mjs index 11534ad..e5c7edd 100644 --- a/scripts/legal-state.mjs +++ b/scripts/legal-state.mjs @@ -10,22 +10,25 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { STATE_FILE, CLAUSE_FILE, TERMS_FILE, findings, renderTerms, activeSince } from './lib/legal-state.mjs'; const HELP = 'usage: node scripts/legal-state.mjs [--check | --write]\n --check (default) refuse a TERMS.md whose 7-day clause block disagrees with legal/legal-state.json\n --write render the block into TERMS.md from the record\n example: edit legal/legal-state.json (a dated commit), then node scripts/legal-state.mjs --write\n'; +function main() { let mode = 'check'; for (const a of process.argv.slice(2)) { - if (a === '-h' || a === '--help') { process.stdout.write(HELP); process.exit(0); } + if (a === '-h' || a === '--help') { process.stdout.write(HELP); process.exitCode = 0; return; } if (a === '--check' || a === '--write') { mode = a.slice(2); continue; } process.stderr.write(`legal-state: unknown argument ${JSON.stringify(a)}\n${HELP}`); - process.exit(2); + process.exitCode = 2; return; } const read = f => (existsSync(f) ? readFileSync(f, 'utf8') : null); const texts = { record: read(STATE_FILE), clause: read(CLAUSE_FILE), terms: read(TERMS_FILE) }; +// A leading BOM is stripped once, so validation and parsing below see the same text (0xFEFF built from the code point, never typed). +if (texts.record !== null && texts.record.charCodeAt(0) === 0xfeff) texts.record = texts.record.slice(1); for (const [name, text] of [[STATE_FILE, texts.record], [CLAUSE_FILE, texts.clause], [TERMS_FILE, texts.terms]]) { - if (text === null) { process.stderr.write(`legal-state: cannot read ${name}\n`); process.exit(2); } + if (text === null) { process.stderr.write(`legal-state: cannot read ${name}\n`); process.exitCode = 2; return; } } if (mode === 'write') { const recordFindings = findings(texts).filter(f => f.kind === 'record' || f.kind === 'markers'); - if (recordFindings.length) { process.stdout.write(recordFindings.map(f => 'legal-state: ' + f.msg).join('\n') + '\n'); process.exit(1); } + if (recordFindings.length) { process.stdout.write(recordFindings.map(f => 'legal-state: ' + f.msg).join('\n') + '\n'); process.exitCode = 1; return; } const out = renderTerms(texts.terms, texts.clause, JSON.parse(texts.record)); if (out.text === texts.terms.replace(/\r\n/g, '\n')) process.stdout.write('legal-state: TERMS.md is already current\n'); else { writeFileSync(TERMS_FILE, out.text); process.stdout.write('legal-state: TERMS.md block rendered from the record\n'); } @@ -37,3 +40,5 @@ if (mode === 'write') { process.stdout.write(`legal-state: the record says the clause is ${since ? 'in force since ' + since : 'not in force'} and TERMS.md agrees\n`); } } +} +main(); diff --git a/scripts/legal-state.test.mjs b/scripts/legal-state.test.mjs index b4976b3..ae19e50 100644 --- a/scripts/legal-state.test.mjs +++ b/scripts/legal-state.test.mjs @@ -119,6 +119,16 @@ test('the record must agree with itself: a company trigger needs sellerType comp assert.equal(ok.status, 0, 'a veto on day 14 is inside the window: ' + ok.stdout + ok.stderr); }); +test('a record that starts with a byte-order mark is read like any other: --write exits 0 and renders, --check passes after', t => { + const bom = String.fromCharCode(0xfeff); + const d = fixture(t, { record: bom + rec({ sellerType: 'company', incorporated: { on: '2026-12-01' } }) }); + const w = run(d, ['--write']); + assert.equal(w.status, 0, w.stdout + w.stderr); + assert.match(termsOf(d), /Binds from 2026-12-01\./); + assert.doesNotMatch(w.stderr, /SyntaxError/); + assert.equal(run(d).status, 0); +}); + test('TERMS.md without the marker pair, or with two pairs, is refused (the check is not vacuous)', t => { const none = run(fixture(t, { terms: '# Terms\n\nNo block.\n' })); assert.equal(none.status, 1, none.stdout + none.stderr);