| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
Establish a canonical subscription plan catalog (#11979) * docs: decide subscription plan authority and migration * feat: establish canonical subscription plan catalog * feat(plans): resolve free quota and chat exhaustion policy * fix(app): preserve unknown subscription plan ids * fix(macos): decode subscription plans losslessly * fix(macos): deny unknown plans paid capability * feat(web): decode subscription plans losslessly * fix(windows): decode subscription plans losslessly * feat: route desktop and phone policy through plan catalog * feat(plans): derive chat policy from catalog * fix(plans): make transcription limits explicit * feat(usage): attribute realized cost to catalog plans * docs(plans): mark the wire zero-sentinel as a deliberate W1-gated bridge The backend retired 0 == unlimited (catalog uses typed {kind: unlimited}, projected as None), but the wire has not been migrated: shipped clients still read 0 as unlimited. The None -> 0 coercion in the subscription response is therefore load-bearing, not a leftover. Annotate it so it is not 'fixed' out of sequence, and branch explicitly on None in the admin reset script where the same coercion would have folded a genuine finite 0 into unlimited. * test(plans): supply _effective_chat_limit to the trial-metadata synthetic namespace test_trial_metadata execs source text extracted from utils/subscription.py in a hand-built namespace. D3a routed get_trial_metadata and TRIAL_FEATURES through the new catalog helper _effective_chat_limit, which the namespace did not provide, so all 19 behavioral trial tests raised NameError. This is the failure mode of source-parsing tests that the design doc calls out when retiring TestStripeEntitlementMismatchScannerDrift: the test does not depend on behavior, it depends on the text of the function it copies. * fix(plans): make the catalog's new failure modes observable and pinned Findings from an independent glm-5.3 review pass, verified against the merge-base before acting: - payment.py: a retained-vs-configured price disagreement now raises where the pre-catalog code let the env mapping win. The bare 'except ValueError: return None' turned that into a silent stop to a paying subscriber's webhook writes. Emit record_fallback + a sanitized error so the Apr 17-20 failure shape is observable rather than log-only. - test_overage_catalog.py: pin chat-limit zero semantics. Base guarded on 'limit_value > 0', so 0 meant unlimited; per David's ruling 0 now means zero and denies. The tests call get_chat_quota_snapshot directly and were mutation-checked: restoring the old guard fails them. - users.py: name the inverse hazard for W1 -- 'or 0' also launders a finite zero into the unlimited sentinel. Latent today, silent capability grant if a plan ever declares a finite-zero allowance. - generate_plan_catalog.py: note that a None base catalog skips the append-only guard, which is correct only for the PR introducing the catalog. * chore(desktop): changelog fragment for the plan-catalog decoder work * test(plans): mirror the new fallback-telemetry names into the payment.py namespace Second instance in this PR of the same failure mode: test_stripe_webhook_behavioral execs source text extracted from routers/payment.py in a hand-built namespace, so the record_fallback/logger/sanitize calls added to the unresolvable-price branch raised NameError there. The test broke on the text of the function it copies, not on its behavior -- which is the argument for retiring source-parsing guards in favour of the catalog-wide check. * fix(web): make the MemoryCard timestamp assertion timezone-independent MemoryCard renders created_at through toLocaleDateString, which formats in the runner's local timezone. The fixture is UTC midnight, so the hardcoded 'Aug 1, 2026' label is 'Jul 31, 2026' on any runner behind UTC and the layout test failed for a reason unrelated to the layout it guards. Already broken on main; it blocks the pre-push web lane for every change touching web/app. Derive the expected label the same way the component does. * feat(firestore): register the hourly-usage plan-attribution query M1's get_usage_by_plan adds a year==/month== compound query on the per-user hourly_usage subcollection, which the coverage ratchet correctly flagged as a new unregistered serving shape. Register it as a query spec rather than baselining it -- baselining a brand-new shape is what the ratchet exists to prevent -- and regenerate firestore.indexes.json from the registry. * fix(plans): fail clearly on an unlimited basic transcription allowance pyright flagged two real errors in the new code. allocation_limit returns None for an unlimited allocation, so the module-level '_BASIC_TIER_SECONDS_DEFAULT // 60' would raise TypeError at import time if the catalog ever declared basic.transcription unlimited. Free is metered by design, so that is an authoring mistake; raise with a sentence that says so instead of crashing on an operator. Also drop the now-unused firestore import left behind in desktop_realtime.py by the cost-attribution work. * chore(api): regenerate the app-client OpenAPI contract cost_usd on RecordLlmUsageBucketRequest is now nullable without a 0.0 default, so an unreported cost stays unreported instead of being recorded as a measured zero. It is a request field, so existing senders are unaffected. No plan enum or plan-bearing response shape changed. * chore(api): regenerate TypeScript clients for the nullable cost_usd Single-line change in each generated client: cost_usd?: number becomes cost_usd?: number | null, matching the OpenAPI regeneration. * fix(plans): restore chat wording, and make the BYOK bypass tests real Three failures surfaced by a per-file isolated sweep of the backend suite. - Plan storefront copy regressed from '{N} chat questions per month' to '{N} questions per month' when the text moved into the catalog. That is user-visible product copy; a consolidation must not reword the product. Restored, with a note saying so. - test_stripe_webhook_none_guard asserted the literal source text 'except ValueError:', which the added telemetry turned into 'except ValueError as e:'. Third source-parsing test in this PR to break on wording rather than behavior; matched on the handler instead. - The BYOK transcription-bypass tests patched utils.byok.get_byok_key, but subscription.py binds that name at import, so the patch never applied and the bypass branch never executed. They passed at base by falling through to the plan-limits path and returning True for an unrelated reason -- the bypass they are named for had no coverage. Patch subscription's own binding and assert the subscription is never consulted, so the short-circuit is actually proven. * fix(plans): legacy zero overlays keep meaning unlimited Independently found by both review passes (codex sol P2, cursor grok P1), and the most consequential defect in this branch. Production sets BASIC_TIER_WORDS_TRANSCRIBED_LIMIT_PER_MONTH and BASIC_TIER_INSIGHTS_GAINED_LIMIT_PER_MONTH to literal '0' on the pre-catalog convention where 0 meant unlimited. Reading them as a finite zero would have given every Free user a zero words/insights allowance and advertised '0 words transcribed per month'. Retiring the sentinel is right; silently reinterpreting already-deployed configuration is not, and it contradicted this PR's own claim that production behavior is unchanged. Legacy overlay values are now read through _legacy_overlay_value, which maps a legacy 0 to unlimited. Applied to the minutes overlay too: charts set 300 so it was latent, but a deployed 0 there would have made has_transcription_credits return False for every Free user. Also: attribute the residual on MIXED usage documents. The first post-deploy write adds plan_usage to a document that already carries pre-deploy root counters; keying only on plan_usage's absence dropped that earlier usage from per-plan reporting entirely. And correct the design doc, which still described B1/B3 as open after the rulings landed. * refactor(plans): assign the Free transcription constants once, with clean types pyright rejected the branch-per-constant form (reportConstantRedefinition) and the string sentinel widened the overlay type to int | str. Return (present, value) from _legacy_overlay and compute both constants in one helper, so each is assigned exactly once and the unlimited case stays typed Optional[int]. * fix(plans): give the Free transcription default a narrowed int type pyright does not carry the module-level None check into the helper's body, so '_BASIC_TIER_SECONDS_DEFAULT // 60' still read as an Optional operand. Narrow once after the guard and annotate the constant as int. * refactor(plans): Stripe owns price amounts; the catalog owns price identity David's ruling, 2026-08-20, revising the original Q1 answer. The repository now stores no dollar amount at all. payment.py already calls stripe.Price.retrieve and renders unit_amount live, so the storefront has always shown Stripe's number; every in-repo dollar figure was display copy, a comment, or a test fixture that nothing compared against Stripe. Removing them dissolves the price-drift problem instead of guarding it -- with one copy there is nothing to drift, and changing a price touches no repository file. Deleted: the amount field on every catalog price, publication_state, the prepare/promote state machine (validate_stripe_publication, price_spec_digest, the --bindings/--stripe-snapshot flags) and its tests, and the publishable gate's 'price has not been imported' errors. Work items P1 and P2 are withdrawn. Kept: the append-only price-id -> plan ledger. Stripe cannot supply that mapping -- it does not know our plan enum -- and an unrecognised price id is exactly what dropped paying subscribers to free in the Apr 17-20 incident. In Git it gets code review, history, and a local lookup that survives a Stripe outage. Storing it in Stripe price metadata was considered and rejected: a typo would become a subscriber-affecting bug with no review and no audit trail. Verified: all ten production and dev price ids still resolve to the correct plan. * fix(desktop): let flow lint see the bridge extension files DesktopAutomationBridge was split into +Notifications and +ChatFirst extension files, but ACTION_SOURCE_RELATIVE_PATHS still listed only the base file. The lint therefore could not see actions those extensions register and reported notifications-settings.yaml as referencing two unknown bridge actions -- a valid flow failing against a stale file list. This was red on main, failing desktop-core-e2e-t0 for every PR touching desktop and blocking the pre-push gate. The contract file's own comment asks for exactly this list to stay complete so an added action cannot skip flow validation. desktop-flow-lint now reports OK: 72 flows, 162 registered actions. * fix(app): drop the now-unused subscription import in settings_drawer The analyzer ratchet allows zero unused_import and reported one. It is ours by reclassification: converting PlanType from an enum to a class changed what models/subscription.dart exposes, so settings_drawer's import went from unnecessary_import (baseline 11, now 10) to outright unused. Nothing in the file referenced a subscription symbol. Verified: analyzer ratchet passes, flutter test 1400 passed / 5 skipped. * test(desktop-chat): stop the BYOK exclusion test building a real Firestore client CI's hermetic network guard failed test_record_usage_skips_byok_requests with BlockedNetworkError against the GCP metadata server. The BYOK skip path passes firestore_client=get_customer_firestore_client(), which is evaluated before the stubbed recorder runs, so a real client was constructed and resolved credentials. It passed locally only because gcloud ADC credentials were present. Stubbing the factory in the test is the correct fix rather than dropping the argument: record_llm_cost_exclusion falls back to the default 'db' when given None, not to the customer client, so removing it would silently change which database exclusions are written to. Verified: 78 passed in test_desktop_chat.py. * fix(usage): stop double-counting attributed questions as unattributed Findings from a glm-5.3 review sweep, each verified against the code first. HIGH -- my own residual-attribution fix was wrong. It read the attributed total from plan_data['questions'], a field no writer writes: questions live at plan_usage.{plan}.{bucket}.quota_questions. The attributed sum was therefore always 0, the residual was the document's FULL root count, and every post-deploy document was counted twice -- once against its real plan and once against _unattributed. questions_by_plan summed to ~2x the truth and fed the per-plan report that MEASUREMENT_CONTRACTS['chat'] points at. Quota enforcement reads the root counters, so no entitlement impact. Now sums recursively the way _accumulate_plan_data traverses, and only creates an _unattributed row when the residual is actually positive. Two regression tests, both failing before. LOW -- the fallback telemetry I added used reason='unrecognized_price', which is not in ALLOWED_REASONS, so bucket_reason relabelled it 'other' and the reason dimension was lost. Now 'config_incomplete'. LOW -- lookup_key validation sat inside the currency-invalid branch, so it only ran when the currency was already wrong. It was publication-machinery schema with no consumer left; removed rather than repaired. Docs: the bridge covers only the Basic overlay family (the chat and Plus overlays are deliberately unbridged because nothing deploys them) -- the doc claimed all three and named a function that does not exist. Also corrected stale claims that survived the publication deletion: catalog-owned 'price amount in minor units', the deleted snapshot validator, --require-publishable reporting P1, and B1/B3/D1 still listed as open. Recorded an unverified dev-Architect ledger gap rather than appending IDs on the evidence of a test comment. | 18 天前 | |
Establish a canonical subscription plan catalog (#11979) * docs: decide subscription plan authority and migration * feat: establish canonical subscription plan catalog * feat(plans): resolve free quota and chat exhaustion policy * fix(app): preserve unknown subscription plan ids * fix(macos): decode subscription plans losslessly * fix(macos): deny unknown plans paid capability * feat(web): decode subscription plans losslessly * fix(windows): decode subscription plans losslessly * feat: route desktop and phone policy through plan catalog * feat(plans): derive chat policy from catalog * fix(plans): make transcription limits explicit * feat(usage): attribute realized cost to catalog plans * docs(plans): mark the wire zero-sentinel as a deliberate W1-gated bridge The backend retired 0 == unlimited (catalog uses typed {kind: unlimited}, projected as None), but the wire has not been migrated: shipped clients still read 0 as unlimited. The None -> 0 coercion in the subscription response is therefore load-bearing, not a leftover. Annotate it so it is not 'fixed' out of sequence, and branch explicitly on None in the admin reset script where the same coercion would have folded a genuine finite 0 into unlimited. * test(plans): supply _effective_chat_limit to the trial-metadata synthetic namespace test_trial_metadata execs source text extracted from utils/subscription.py in a hand-built namespace. D3a routed get_trial_metadata and TRIAL_FEATURES through the new catalog helper _effective_chat_limit, which the namespace did not provide, so all 19 behavioral trial tests raised NameError. This is the failure mode of source-parsing tests that the design doc calls out when retiring TestStripeEntitlementMismatchScannerDrift: the test does not depend on behavior, it depends on the text of the function it copies. * fix(plans): make the catalog's new failure modes observable and pinned Findings from an independent glm-5.3 review pass, verified against the merge-base before acting: - payment.py: a retained-vs-configured price disagreement now raises where the pre-catalog code let the env mapping win. The bare 'except ValueError: return None' turned that into a silent stop to a paying subscriber's webhook writes. Emit record_fallback + a sanitized error so the Apr 17-20 failure shape is observable rather than log-only. - test_overage_catalog.py: pin chat-limit zero semantics. Base guarded on 'limit_value > 0', so 0 meant unlimited; per David's ruling 0 now means zero and denies. The tests call get_chat_quota_snapshot directly and were mutation-checked: restoring the old guard fails them. - users.py: name the inverse hazard for W1 -- 'or 0' also launders a finite zero into the unlimited sentinel. Latent today, silent capability grant if a plan ever declares a finite-zero allowance. - generate_plan_catalog.py: note that a None base catalog skips the append-only guard, which is correct only for the PR introducing the catalog. * chore(desktop): changelog fragment for the plan-catalog decoder work * test(plans): mirror the new fallback-telemetry names into the payment.py namespace Second instance in this PR of the same failure mode: test_stripe_webhook_behavioral execs source text extracted from routers/payment.py in a hand-built namespace, so the record_fallback/logger/sanitize calls added to the unresolvable-price branch raised NameError there. The test broke on the text of the function it copies, not on its behavior -- which is the argument for retiring source-parsing guards in favour of the catalog-wide check. * fix(web): make the MemoryCard timestamp assertion timezone-independent MemoryCard renders created_at through toLocaleDateString, which formats in the runner's local timezone. The fixture is UTC midnight, so the hardcoded 'Aug 1, 2026' label is 'Jul 31, 2026' on any runner behind UTC and the layout test failed for a reason unrelated to the layout it guards. Already broken on main; it blocks the pre-push web lane for every change touching web/app. Derive the expected label the same way the component does. * feat(firestore): register the hourly-usage plan-attribution query M1's get_usage_by_plan adds a year==/month== compound query on the per-user hourly_usage subcollection, which the coverage ratchet correctly flagged as a new unregistered serving shape. Register it as a query spec rather than baselining it -- baselining a brand-new shape is what the ratchet exists to prevent -- and regenerate firestore.indexes.json from the registry. * fix(plans): fail clearly on an unlimited basic transcription allowance pyright flagged two real errors in the new code. allocation_limit returns None for an unlimited allocation, so the module-level '_BASIC_TIER_SECONDS_DEFAULT // 60' would raise TypeError at import time if the catalog ever declared basic.transcription unlimited. Free is metered by design, so that is an authoring mistake; raise with a sentence that says so instead of crashing on an operator. Also drop the now-unused firestore import left behind in desktop_realtime.py by the cost-attribution work. * chore(api): regenerate the app-client OpenAPI contract cost_usd on RecordLlmUsageBucketRequest is now nullable without a 0.0 default, so an unreported cost stays unreported instead of being recorded as a measured zero. It is a request field, so existing senders are unaffected. No plan enum or plan-bearing response shape changed. * chore(api): regenerate TypeScript clients for the nullable cost_usd Single-line change in each generated client: cost_usd?: number becomes cost_usd?: number | null, matching the OpenAPI regeneration. * fix(plans): restore chat wording, and make the BYOK bypass tests real Three failures surfaced by a per-file isolated sweep of the backend suite. - Plan storefront copy regressed from '{N} chat questions per month' to '{N} questions per month' when the text moved into the catalog. That is user-visible product copy; a consolidation must not reword the product. Restored, with a note saying so. - test_stripe_webhook_none_guard asserted the literal source text 'except ValueError:', which the added telemetry turned into 'except ValueError as e:'. Third source-parsing test in this PR to break on wording rather than behavior; matched on the handler instead. - The BYOK transcription-bypass tests patched utils.byok.get_byok_key, but subscription.py binds that name at import, so the patch never applied and the bypass branch never executed. They passed at base by falling through to the plan-limits path and returning True for an unrelated reason -- the bypass they are named for had no coverage. Patch subscription's own binding and assert the subscription is never consulted, so the short-circuit is actually proven. * fix(plans): legacy zero overlays keep meaning unlimited Independently found by both review passes (codex sol P2, cursor grok P1), and the most consequential defect in this branch. Production sets BASIC_TIER_WORDS_TRANSCRIBED_LIMIT_PER_MONTH and BASIC_TIER_INSIGHTS_GAINED_LIMIT_PER_MONTH to literal '0' on the pre-catalog convention where 0 meant unlimited. Reading them as a finite zero would have given every Free user a zero words/insights allowance and advertised '0 words transcribed per month'. Retiring the sentinel is right; silently reinterpreting already-deployed configuration is not, and it contradicted this PR's own claim that production behavior is unchanged. Legacy overlay values are now read through _legacy_overlay_value, which maps a legacy 0 to unlimited. Applied to the minutes overlay too: charts set 300 so it was latent, but a deployed 0 there would have made has_transcription_credits return False for every Free user. Also: attribute the residual on MIXED usage documents. The first post-deploy write adds plan_usage to a document that already carries pre-deploy root counters; keying only on plan_usage's absence dropped that earlier usage from per-plan reporting entirely. And correct the design doc, which still described B1/B3 as open after the rulings landed. * refactor(plans): assign the Free transcription constants once, with clean types pyright rejected the branch-per-constant form (reportConstantRedefinition) and the string sentinel widened the overlay type to int | str. Return (present, value) from _legacy_overlay and compute both constants in one helper, so each is assigned exactly once and the unlimited case stays typed Optional[int]. * fix(plans): give the Free transcription default a narrowed int type pyright does not carry the module-level None check into the helper's body, so '_BASIC_TIER_SECONDS_DEFAULT // 60' still read as an Optional operand. Narrow once after the guard and annotate the constant as int. * refactor(plans): Stripe owns price amounts; the catalog owns price identity David's ruling, 2026-08-20, revising the original Q1 answer. The repository now stores no dollar amount at all. payment.py already calls stripe.Price.retrieve and renders unit_amount live, so the storefront has always shown Stripe's number; every in-repo dollar figure was display copy, a comment, or a test fixture that nothing compared against Stripe. Removing them dissolves the price-drift problem instead of guarding it -- with one copy there is nothing to drift, and changing a price touches no repository file. Deleted: the amount field on every catalog price, publication_state, the prepare/promote state machine (validate_stripe_publication, price_spec_digest, the --bindings/--stripe-snapshot flags) and its tests, and the publishable gate's 'price has not been imported' errors. Work items P1 and P2 are withdrawn. Kept: the append-only price-id -> plan ledger. Stripe cannot supply that mapping -- it does not know our plan enum -- and an unrecognised price id is exactly what dropped paying subscribers to free in the Apr 17-20 incident. In Git it gets code review, history, and a local lookup that survives a Stripe outage. Storing it in Stripe price metadata was considered and rejected: a typo would become a subscriber-affecting bug with no review and no audit trail. Verified: all ten production and dev price ids still resolve to the correct plan. * fix(desktop): let flow lint see the bridge extension files DesktopAutomationBridge was split into +Notifications and +ChatFirst extension files, but ACTION_SOURCE_RELATIVE_PATHS still listed only the base file. The lint therefore could not see actions those extensions register and reported notifications-settings.yaml as referencing two unknown bridge actions -- a valid flow failing against a stale file list. This was red on main, failing desktop-core-e2e-t0 for every PR touching desktop and blocking the pre-push gate. The contract file's own comment asks for exactly this list to stay complete so an added action cannot skip flow validation. desktop-flow-lint now reports OK: 72 flows, 162 registered actions. * fix(app): drop the now-unused subscription import in settings_drawer The analyzer ratchet allows zero unused_import and reported one. It is ours by reclassification: converting PlanType from an enum to a class changed what models/subscription.dart exposes, so settings_drawer's import went from unnecessary_import (baseline 11, now 10) to outright unused. Nothing in the file referenced a subscription symbol. Verified: analyzer ratchet passes, flutter test 1400 passed / 5 skipped. * test(desktop-chat): stop the BYOK exclusion test building a real Firestore client CI's hermetic network guard failed test_record_usage_skips_byok_requests with BlockedNetworkError against the GCP metadata server. The BYOK skip path passes firestore_client=get_customer_firestore_client(), which is evaluated before the stubbed recorder runs, so a real client was constructed and resolved credentials. It passed locally only because gcloud ADC credentials were present. Stubbing the factory in the test is the correct fix rather than dropping the argument: record_llm_cost_exclusion falls back to the default 'db' when given None, not to the customer client, so removing it would silently change which database exclusions are written to. Verified: 78 passed in test_desktop_chat.py. * fix(usage): stop double-counting attributed questions as unattributed Findings from a glm-5.3 review sweep, each verified against the code first. HIGH -- my own residual-attribution fix was wrong. It read the attributed total from plan_data['questions'], a field no writer writes: questions live at plan_usage.{plan}.{bucket}.quota_questions. The attributed sum was therefore always 0, the residual was the document's FULL root count, and every post-deploy document was counted twice -- once against its real plan and once against _unattributed. questions_by_plan summed to ~2x the truth and fed the per-plan report that MEASUREMENT_CONTRACTS['chat'] points at. Quota enforcement reads the root counters, so no entitlement impact. Now sums recursively the way _accumulate_plan_data traverses, and only creates an _unattributed row when the residual is actually positive. Two regression tests, both failing before. LOW -- the fallback telemetry I added used reason='unrecognized_price', which is not in ALLOWED_REASONS, so bucket_reason relabelled it 'other' and the reason dimension was lost. Now 'config_incomplete'. LOW -- lookup_key validation sat inside the currency-invalid branch, so it only ran when the currency was already wrong. It was publication-machinery schema with no consumer left; removed rather than repaired. Docs: the bridge covers only the Basic overlay family (the chat and Plus overlays are deliberately unbridged because nothing deploys them) -- the doc claimed all three and named a function that does not exist. Also corrected stale claims that survived the publication deletion: catalog-owned 'price amount in minor units', the deleted snapshot validator, --require-publishable reporting P1, and B1/B3/D1 still listed as open. Recorded an unverified dev-Architect ledger gap rather than appending IDs on the evidence of a test comment. | 18 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 18 天前 | ||
| 18 天前 |