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/CHANGELOG.md b/CHANGELOG.md index 9d93df3..11ccec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,27 @@ 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 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 + 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 @@ -184,10 +205,10 @@ 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 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/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/TERMS.md b/TERMS.md index 5cd7601..b6fd532 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 @@ -153,56 +151,62 @@ 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 เห็นว่าลูกค้าฝ่าฝืนกฎ** +> รายการพฤติกรรมที่ห้ามยังไม่ได้เขียน เป็นเรื่องที่เจ้าของและที่ปรึกษากฎหมายต้องตัดสิน และหัวข้อนี้ไม่เดาแทน +> สิ่งที่เขียนไว้คือวิธีที่ Kolwen ปฏิบัติเมื่อเห็นว่าลูกค้าฝ่าฝืนกฎ ตามลำดับนี้ +> 1. แจ้งข้อกำหนดที่เห็นว่าฝ่าฝืน พร้อมสรุปพยานหลักฐาน +> 2. ระงับบัญชี ไม่ใช่เลิกสัญญา ระหว่างที่เคสยังเปิด ถ้าสินค้าเป็นคีย์ นาฬิกานับวันของคีย์หยุดตลอดทั้งเคส +> 3. ลูกค้ามีเวลา **14 วันทำการ** เพื่อตอบข้อแจ้ง +> 4. คำตัดสินเป็นลายลักษณ์อักษรภายใน **7 วันทำการ** หลังลูกค้าตอบ ยกคำร้องและคืนบัญชีกับคีย์เต็มที่ หรือยืนตามและเลิกสัญญา +> เงินคืนเป็นไปตามผล ยกคำร้องคืนสถานะเต็มที่ ยืนตามไม่คืนเงิน +> 5. อุทธรณ์ต่อมนุษย์ได้หนึ่งครั้งเมื่อคำตัดสินยืนตาม ภายใน **30 วันทำการ** หลังคำตัดสิน ผ่าน **appeals@kolwen.com** และ Kolwen +> ตอบภายใน **14 วันทำการ** +> 6. เก็บทะเบียนเคส เพื่อให้พฤติกรรมเดียวกันได้คำตอบเดียวกัน +> +> **วันทำการ** ในหัวข้อนี้คือวันทำงานปกติในประเทศไทย ตั้งแต่วันจันทร์ถึงวันศุกร์ ไม่รวมวันหยุดราชการที่สำนักนายกรัฐมนตรีประกาศในราชกิจจานุเบกษา +> และไม่ใช้วันหยุดของธนาคาร +> +> **เพดานและผลของการพลาดกำหนด** เคสหนึ่งยาวไม่เกิน **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), and the day counts and the appeal channel above are placeholders -the owner signs later.** - -## 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 **[cancellation notice channel]**, 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 ผู้บริโภคในประเทศไทยแจ้งยกเลิกผ่านช่องทาง **[ช่องทางแจ้งยกเลิก]** ข้อนี้จะมีผลเมื่อครบเงื่อนไขข้อใดข้อหนึ่งก่อน คือ ผู้ขายจดทะเบียนเป็นนิติบุคคล -> จดทะเบียนเป็นผู้ประกอบธุรกิจตลาดแบบตรง หรือมีรายได้เกิน 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.** +(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.** + + + ## Retention 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 diff --git a/governance/policies.md b/governance/policies.md index 1c28377..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); 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/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..ae07b2f --- /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 to offer a +trial before payment, never a waiver of this right. + +> **[รอที่ปรึกษากฎหมาย] สิทธิยกเลิกของผู้บริโภคในประเทศไทย** +> ตามพระราชบัญญัติขายตรงและตลาดแบบตรง พ.ศ. 2545 ผู้บริโภคที่ซื้อจากผู้ประกอบธุรกิจที่ขายตรงหรือขายผ่านตลาดแบบตรง +> ยกเลิกได้ภายใน 7 วันนับแต่ได้รับบริการ และได้รับเงินคืนเต็มจำนวน โดยจ่ายคืนภายใน 15 วัน +> นับแต่ 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 +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/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 diff --git a/scripts/legal-state.mjs b/scripts/legal-state.mjs new file mode 100644 index 0000000..e5c7edd --- /dev/null +++ b/scripts/legal-state.mjs @@ -0,0 +1,44 @@ +#!/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'; +function main() { +let mode = 'check'; +for (const a of process.argv.slice(2)) { + 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.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.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.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'); } +} 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`); + } +} +} +main(); diff --git a/scripts/legal-state.test.mjs b/scripts/legal-state.test.mjs new file mode 100644 index 0000000..ae19e50 --- /dev/null +++ b/scripts/legal-state.test.mjs @@ -0,0 +1,159 @@ +// 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 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/], + ]; + 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('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); + 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..afc539c --- /dev/null +++ b/scripts/lib/legal-state.mjs @@ -0,0 +1,101 @@ +// 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'); + 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'); + 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; +} diff --git a/scripts/surface-check.mjs b/scripts/surface-check.mjs index db62384..fdf5883 100644 --- a/scripts/surface-check.mjs +++ b/scripts/surface-check.mjs @@ -3,6 +3,11 @@ // 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. +// +// 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'; @@ -244,7 +249,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 +859,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);