[object Object]

← back to Dw Daily Catchup 20260909

Add dated daily activation catch-up while retaining the 500 cap

5e650c109e6d6b99d8787390393163f3ff6af28a · 2026-09-09 11:29:27 -0700 · Steve Abrams

Files touched

Diff

commit 5e650c109e6d6b99d8787390393163f3ff6af28a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 11:29:27 2026 -0700

    Add dated daily activation catch-up while retaining the 500 cap
---
 config/daily-catchup-2026-09-09.json        | 10 ++++
 lib/activation-allowance.js                 | 29 +++++++++
 rotate-activate.js                          | 20 +++++--
 scripts/verify-daily-catchup-independent.js | 93 +++++++++++++++++++++++++++++
 test/activation-allowance.test.js           | 43 +++++++++++++
 verification/daily-catchup-decision.txt     | 11 ++++
 6 files changed, 201 insertions(+), 5 deletions(-)

diff --git a/config/daily-catchup-2026-09-09.json b/config/daily-catchup-2026-09-09.json
new file mode 100644
index 0000000..4685757
--- /dev/null
+++ b/config/daily-catchup-2026-09-09.json
@@ -0,0 +1,10 @@
+{
+  "schema_version": 1,
+  "ticket": "TK-11326",
+  "date": "2026-09-09",
+  "approved_by": "Steve",
+  "authorization": "start loading for the day now",
+  "starting_daily_used": 37,
+  "max_daily": 500,
+  "scope": "One-time catch-up for this date. Skip hourly pacing only when explicitly invoked; preserve the fixed vendor authorization, all other gates, zero Gemini and daily500."
+}
diff --git a/lib/activation-allowance.js b/lib/activation-allowance.js
new file mode 100644
index 0000000..8ece3e6
--- /dev/null
+++ b/lib/activation-allowance.js
@@ -0,0 +1,29 @@
+'use strict';
+
+function assertDailyCatchup(date, authorization, now = new Date()) {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(date || '') || date !== now.toISOString().slice(0, 10) ||
+      authorization?.schema_version !== 1 || authorization.ticket !== 'TK-11326' ||
+      authorization.date !== date || authorization.approved_by !== 'Steve' ||
+      authorization.authorization !== 'start loading for the day now' ||
+      authorization.starting_daily_used !== 37 || authorization.max_daily !== 500)
+    throw new Error('Daily catch-up authorization is invalid or expired');
+}
+
+function activationAllowance({ dailyUsed, hourlyUsed, requested = null, dailyCap = 500, hourlyCap = 21,
+  catchupDate = null, authorization = null, now = new Date() }) {
+  if (![dailyUsed, hourlyUsed].every(n => Number.isSafeInteger(n) && n >= 0) ||
+      !Number.isSafeInteger(dailyCap) || dailyCap < 1 || dailyCap > 500 ||
+      !Number.isSafeInteger(hourlyCap) || hourlyCap < 1 || hourlyCap > 21 ||
+      (requested !== null && (!Number.isSafeInteger(requested) || requested < 1)))
+    throw new Error('Invalid activation limits; refusing an unbounded run');
+  const dailyRemaining = Math.max(0, dailyCap - dailyUsed);
+  const hourlyRemaining = Math.max(0, hourlyCap - hourlyUsed);
+  if (catchupDate !== null) {
+    assertDailyCatchup(catchupDate, authorization, now);
+    if (dailyUsed < authorization.starting_daily_used) throw new Error('Daily ledger is below approved catch-up baseline');
+    return { cap: Math.min(requested ?? dailyRemaining, dailyRemaining), mode: 'daily-catchup', dailyRemaining, hourlyRemaining };
+  }
+  return { cap: Math.min(requested ?? dailyRemaining, dailyRemaining, hourlyRemaining), mode: 'hourly', dailyRemaining, hourlyRemaining };
+}
+
+module.exports = { activationAllowance, assertDailyCatchup };
diff --git a/rotate-activate.js b/rotate-activate.js
index 5d3d33f..faa9b56 100644
--- a/rotate-activate.js
+++ b/rotate-activate.js
@@ -51,6 +51,7 @@ const { canonicalProduct, widthFromLive, completeLiveProduct } = require('./lib/
 const { buildRepairReport } = require('./lib/readiness-repairs.js');
 const { confirmedActivation } = require('./lib/activation-result.js');
 const { countHourlyActivations } = require('./lib/hourly-activation-count.js');
+const { activationAllowance, assertDailyCatchup } = require('./lib/activation-allowance.js');
 const { reusedMfrSet, stagingColorFor } = require('./lib/mfr-gate-resolve.js');
 // Canonical showroom-vendor primitive (list + logic live in fix-live-board/config). Showroom-only
 // vendors are addressable-not-discoverable: never rotate them into the New Arrivals discoverability
@@ -66,6 +67,9 @@ const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] :
 const COMMIT = flag('--commit');
 const DRY = flag('--dry-run') || !COMMIT;   // safe-by-default: no --commit ⇒ dry-run
 const CLI_MAX = val('--max', null);
+const CATCHUP_DATE = flag('--daily-catchup') ? val('--daily-catchup', '') : null;
+const CATCHUP_AUTHORIZATION = CATCHUP_DATE !== null ? require('./config/daily-catchup-2026-09-09.json') : null;
+if (CATCHUP_DATE !== null) assertDailyCatchup(CATCHUP_DATE, CATCHUP_AUTHORIZATION);
 
 const STORE = 'designer-laboratory-sandbox.myshopify.com';
 const API = '2024-10';
@@ -75,6 +79,7 @@ const AUDIT = path.join(OUTDIR, `rotation-activations-${TODAY}.jsonl`);
 const REPAIR_REPORT = path.join(OUTDIR, 'current-readiness-repairs.json');
 const runDecisions = [];
 function recordDecision(record) {
+  if (CATCHUP_DATE !== null) record.pacing_authorization = { mode: 'daily-catchup', ticket: 'TK-11326', date: CATCHUP_DATE };
   fs.appendFileSync(AUDIT, JSON.stringify(record) + '\n');
   runDecisions.push(record);
 }
@@ -279,16 +284,18 @@ function leakGuard(title, vendor) {
   if (DRY) {
     cap = CLI_MAX != null ? parseInt(CLI_MAX, 10) : 50;
   } else {
-    const remaining = Math.max(0, DAILY_ACTIVATION_CAP - used);
-    const want = CLI_MAX != null ? parseInt(CLI_MAX, 10) : remaining;
     const hourlyUsed = countHourlyActivations(fs.existsSync(AUDIT) ? fs.readFileSync(AUDIT, 'utf8') : '');
-    const hourlyRemaining = Math.max(0, HOURLY_ACTIVATION_CAP - hourlyUsed);
-    cap = Math.min(want, remaining, hourlyRemaining);
+    const allowance = activationAllowance({ dailyUsed: used, hourlyUsed,
+      requested: CLI_MAX !== null ? Number(CLI_MAX) : null, dailyCap: DAILY_ACTIVATION_CAP,
+      hourlyCap: HOURLY_ACTIVATION_CAP, catchupDate: CATCHUP_DATE, authorization: CATCHUP_AUTHORIZATION });
+    cap = allowance.cap;
     if (cap <= 0) {
       console.log(`[budget] activation allowance reached (daily ${used}/${DAILY_ACTIVATION_CAP}, hourly ${hourlyUsed}/${HOURLY_ACTIVATION_CAP}). Nothing to do this run.`);
       return;
     }
-    console.log(`[budget] hourly activation lane: used ${hourlyUsed}/${HOURLY_ACTIVATION_CAP}; manual and scheduled runs share this allowance.`);
+    console.log(allowance.mode === 'daily-catchup'
+      ? `[budget] one-time daily catch-up: ${CATCHUP_DATE} TK-11326; hourly pacing skipped, daily cap remains ${DAILY_ACTIVATION_CAP}.`
+      : `[budget] hourly activation lane: used ${hourlyUsed}/${HOURLY_ACTIVATION_CAP}; manual and scheduled runs share this allowance.`);
     console.log(`[budget] activation lane: used ${used}/${DAILY_ACTIVATION_CAP} today → activating up to ${cap} this run.`);
   }
   console.log(`mode=${DRY ? 'DRY-RUN' : 'COMMIT'}  cap_this_run=${cap}\n`);
@@ -306,6 +313,7 @@ function leakGuard(title, vendor) {
     if (r.status !== 200 || r.json?.errors?.length || !Array.isArray(r.json?.data?.nodes))
       throw new Error('Shopify product batch read failed; activation stopped');
     for (const initialNode of r.json.data.nodes) {
+      if (CATCHUP_DATE !== null) assertDailyCatchup(CATCHUP_DATE, CATCHUP_AUTHORIZATION);
       if (activated >= cap) break;
       if (!initialNode) continue;
       let n = initialNode;
@@ -407,6 +415,8 @@ function leakGuard(title, vendor) {
       }
 
       // COMMIT: flip → ACTIVE, add 'New Arrival', publish to channels (ex-Google).
+      if (CATCHUP_DATE !== null) assertDailyCatchup(CATCHUP_DATE, CATCHUP_AUTHORIZATION);
+      if (ledgerUsed() >= DAILY_ACTIVATION_CAP) throw new Error('Daily activation cap reached during run');
       const ar = await gqlRetry(ACTIVATE, { id: n.id });
       const aue = ar.json?.data?.productUpdate?.userErrors;
       if (!confirmedActivation(ar, n.id)) {
diff --git a/scripts/verify-daily-catchup-independent.js b/scripts/verify-daily-catchup-independent.js
new file mode 100644
index 0000000..918ec99
--- /dev/null
+++ b/scripts/verify-daily-catchup-independent.js
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+'use strict';
+// Independent read-only API/storefront verifier. No activation-module imports.
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const assert = require('node:assert/strict');
+const [phase, expectedArg] = process.argv.slice(2);
+if (!['before', 'after'].includes(phase)) throw new Error('Usage: verifier before | after <cumulative new count>');
+const evidence = path.resolve('verification/tk11326');
+const production = path.join(os.homedir(), 'Projects/dw-rotation-activator');
+const day = new Date().toISOString().slice(0, 10);
+const auditPath = path.join(production, `out/rotation-activations-${day}.jsonl`);
+const ledgerPath = path.join(production, `out/activation-ledger-${day}.json`);
+const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+const token = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1]?.trim().replace(/^['"]|['"]$/g, '');
+const policy = JSON.parse(fs.readFileSync('config/gemini-review-policy.json'));
+const blocked = new Set(policy.historical_blocked_ids);
+const candidates = fs.readFileSync(`out/rotation-activations-${day}.jsonl`, 'utf8').trim().split('\n').map(JSON.parse);
+const predicted = candidates.filter(r => r.action === 'dryrun-would-activate').map(r => r.shopify_id);
+assert.equal(predicted.length, 462); assert.equal(new Set(predicted).size, 462);
+const negativeIds = [...new Set(candidates.filter(r => r.action === 'settlement-block' || r.action === 'skip-gate').map(r => r.shopify_id))].filter(id => !predicted.includes(id));
+const controls = [...new Set([...negativeIds.filter(id => blocked.has(id)), ...negativeIds.filter(id => !blocked.has(id)).slice(0, 2)])];
+const query = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
+ id title vendor status handle tags images(first:1){nodes{url}}
+ variants(first:100){nodes{id sku title price} pageInfo{hasNextPage}}
+ review:metafield(namespace:"internal",key:"settlement_hold"){value}
+ googlePublished:publishedOnPublication(publicationId:"gid://shopify/Publication/29646651457")
+ resourcePublicationsV2(first:50){nodes{isPublished publication{id name}} pageInfo{hasNextPage}}
+}}}`;
+async function readProducts(ids) {
+ const products = [];
+ for (let i=0; i<ids.length; i+=40) {
+  const response = await fetch('https://designer-laboratory-sandbox.myshopify.com/admin/api/2026-07/graphql.json', {
+   method:'POST', headers:{'Content-Type':'application/json','X-Shopify-Access-Token':token},
+   body:JSON.stringify({query,variables:{ids:ids.slice(i,i+40)}}), signal:AbortSignal.timeout(30000),
+  });
+  const result=await response.json();
+  assert.equal(response.status,200); assert.equal(result.errors,undefined,JSON.stringify(result.errors));
+  assert.equal(result.data.nodes.length,ids.slice(i,i+40).length);
+  assert.ok(result.data.nodes.every(Boolean)); products.push(...result.data.nodes);
+ }
+ return products;
+}
+async function main() {
+ if (phase==='before') {
+  const products=await readProducts([...predicted,...controls]);
+  assert.ok(products.every(p=>p.status==='DRAFT'));
+  assert.ok(products.every(p=>!p.variants.pageInfo.hasNextPage && !p.resourcePublicationsV2.pageInfo.hasNextPage));
+  const report={timestamp:new Date().toISOString(),mutations:0,predicted,controls,products,
+   ledger:JSON.parse(fs.readFileSync(ledgerPath)),audit_offset:fs.statSync(auditPath).size,audit_path:auditPath};
+  fs.writeFileSync(path.join(evidence,'live-before.json'),JSON.stringify(report,null,2)+'\n');
+  console.log(JSON.stringify({phase,products:products.length,candidates:predicted.length,controls:controls.length,ledger:report.ledger,first:products[0].title}));
+  return;
+ }
+ const expected=Number(expectedArg); assert.ok([1,462].includes(expected));
+ const before=JSON.parse(fs.readFileSync(path.join(evidence,'live-before.json')));
+ const rows=fs.readFileSync(auditPath).subarray(before.audit_offset).toString().trim().split('\n').filter(Boolean).map(JSON.parse);
+ const activated=rows.filter(r=>r.action==='activated');
+ assert.equal(activated.length,expected);
+ assert.equal(new Set(activated.map(r=>r.shopify_id)).size,expected);
+ assert.ok(activated.every(r=>predicted.includes(r.shopify_id) && !blocked.has(r.shopify_id)));
+ assert.ok(activated.every(r=>r.pacing_authorization?.ticket==='TK-11326' && r.pacing_authorization.date==='2026-09-09'));
+ assert.ok(activated.every(r=>r.passes===true && r.published===true && r.settlement.verdict==='SKIPPED_BY_OWNER' && r.settlement.review_performed===false && r.settlement.authorization.ticket==='TK-11321' && r.settlement.cost===0));
+ const ids=[...activated.map(r=>r.shopify_id),...controls];
+ const products=await readProducts(ids);
+ const old=new Map(before.products.map(p=>[p.id,p]));
+ for (const product of products) {
+  if (controls.includes(product.id)) { assert.equal(product.status,'DRAFT','Negative control was published'); continue; }
+  assert.equal(product.status,'ACTIVE'); assert.ok(product.tags.includes('New Arrival'));
+  assert.equal(product.googlePublished,false);
+  assert.deepEqual(product.variants,old.get(product.id).variants,'Variant or price changed');
+  assert.deepEqual(product.images,old.get(product.id).images,'Primary image changed');
+  assert.equal(product.title,old.get(product.id).title); assert.equal(product.vendor,old.get(product.id).vendor);
+  assert.ok(product.resourcePublicationsV2.nodes.some(p=>p.isPublished && p.publication.name==='Online Store'));
+  const review=JSON.parse(product.review.value);
+  assert.equal(review.verdict,'SKIPPED_BY_OWNER'); assert.equal(review.reason,'owner-authorized-no-gemini:TK-11321');
+ }
+ const ledger=JSON.parse(fs.readFileSync(ledgerPath)); assert.equal(ledger.used,before.ledger.used+expected);
+ const storefront=[];
+ for (const product of [products[0],...(expected>1?[products[expected-1]]:[])]) {
+  const url=`https://www.designerwallcoverings.com/products/${product.handle}`;
+  const response=await fetch(url,{signal:AbortSignal.timeout(30000)}); const html=await response.text();
+  assert.equal(response.status,200); assert.ok(html.includes(product.id.split('/').pop()));
+  storefront.push({url,status:200,product_id_found:true});
+ }
+ const report={timestamp:new Date().toISOString(),verdict:'PASS',mutations:0,newly_activated:expected,
+  ledger_before:before.ledger,ledger_after:ledger,controls_still_draft:controls.length,activated,products,storefront,
+  independent_verifier:'Separate read-only Admin API2026-07 and public PDP reads; no activation imports'};
+ fs.writeFileSync(path.join(evidence,`live-verified-${expected}.json`),JSON.stringify(report,null,2)+'\n');
+ console.log(JSON.stringify({verdict:report.verdict,newly_activated:expected,ledger,controls_still_draft:controls.length,storefront}));
+}
+main().catch(error=>{console.error(error.message);process.exitCode=1;});
diff --git a/test/activation-allowance.test.js b/test/activation-allowance.test.js
new file mode 100644
index 0000000..68cf9fd
--- /dev/null
+++ b/test/activation-allowance.test.js
@@ -0,0 +1,43 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { activationAllowance, assertDailyCatchup } = require('../lib/activation-allowance');
+const authorization = require('../config/daily-catchup-2026-09-09.json');
+const base = { dailyUsed: 37, hourlyUsed: 21, requested: 463, now: new Date('2026-09-09T18:20:00Z') };
+const catchup = extra => ({ ...base, catchupDate: '2026-09-09', authorization, ...extra });
+test('normal scheduled runs retain the21/hour cap', () => {
+  assert.equal(activationAllowance(base).cap, 0);
+  assert.equal(activationAllowance({ ...base, hourlyUsed: 0 }).cap, 21);
+  assert.equal(activationAllowance({ ...base, hourlyUsed: 20 }).cap, 1);
+});
+test('explicit current-day catch-up can use remaining daily allowance', () => {
+  const result = activationAllowance(catchup());
+  assert.equal(result.cap, 463); assert.equal(result.mode, 'daily-catchup');
+  assert.equal(activationAllowance(catchup({ requested: 1 })).cap, 1);
+});
+test('retries cannot exceed500 total activations', () => {
+  assert.equal(activationAllowance(catchup({ dailyUsed: 499 })).cap, 1);
+  assert.equal(activationAllowance(catchup({ dailyUsed: 500 })).cap, 0);
+  assert.equal(activationAllowance(catchup({ dailyUsed: 501 })).cap, 0);
+});
+test('having an authorization file does not enable catch-up for a scheduled invocation', () => {
+  assert.equal(activationAllowance({ ...base, authorization }).cap, 0);
+});
+test('missing, altered, future and expired authorizations fail closed', () => {
+  for (const change of [{ authorization: null }, { authorization: { ...authorization, max_daily: 501 } },
+    { authorization: { ...authorization, approved_by: 'someone else' } }, { catchupDate: '2026-09-10' },
+    { now: new Date('2026-09-10T00:00:00Z') }, { now: new Date('2026-09-08T23:59:59Z') }])
+    assert.throws(() => activationAllowance(catchup(change)));
+});
+test('the publication-loop date check stops a run crossing midnight', () => {
+  assert.doesNotThrow(() => assertDailyCatchup('2026-09-09', authorization, new Date('2026-09-09T23:59:59Z')));
+  assert.throws(() => assertDailyCatchup('2026-09-09', authorization, new Date('2026-09-10T00:00:00Z')));
+});
+test('a reset ledger cannot grant the original allowance again', () => {
+  assert.throws(() => activationAllowance(catchup({ dailyUsed: 0 })));
+});
+test('malformed and inflated limits cannot disable either cap', () => {
+  for (const change of [{ requested: NaN }, { requested: -1 }, { requested: 0 }, { requested: Infinity },
+    { dailyCap: 501 }, { hourlyCap: 22 }, { dailyUsed: NaN }, { hourlyUsed: -1 }])
+    assert.throws(() => activationAllowance(catchup(change)));
+});
diff --git a/verification/daily-catchup-decision.txt b/verification/daily-catchup-decision.txt
new file mode 100644
index 0000000..83dbdbd
--- /dev/null
+++ b/verification/daily-catchup-decision.txt
@@ -0,0 +1,11 @@
+task_id: TK-11326; owner/finalizer: root Codex; delegation_chain: root -> DTD reviewers; depth1; no redelegation.
+Read-only design review only; no edits, external writes, publishing, billing or credentials. Parent records evidence on canonical ticket. Zero-cost model mode mandatory.
+
+Steve told the assistant 'start loading for the day now' after 37 DW vendor products had activated today and the21/hour slot was full. He previously explicitly authorized skipping Gemini for the fixed504 provider-outage-held vendor cohort;21 known BLOCKs remain excluded and21 of483 candidate records were already activated, leaving at most462 authorized candidates before fresh checks. Daily cap500 leaves463 available slots. The requested operational result is loading today's remaining eligible products now.
+
+Choose implementation:
+A: Explicit date-bound one-time catch-up authorization for2026-09-09/TK-11326, activated only by a dedicated CLI flag. Skip hourly pacing for this invocation only, never increase the500/day cap or owner cohort. Invalid/stale dates and midnight crossing stop. Fresh data/vendor/price/sample/known-BLOCK guards and zeroGemini policy remain. Default scheduled path stays21/hour. Hold the existing singleton lock; live read-only plan + independent before-state + one-product canary and full batch verification.
+B: Permanently remove the hourly pacing for every scheduled run and raise the daily cap if candidate supply exceeds500.
+C: Leave the user's day-loading request waiting until the next hourly slot without implementing a catch-up path.
+
+Pick exactly one option. Begin your answer with VERDICT: <option> on its own line, then one paragraph with main risk and one concrete regression test. Implementation advice only, not legal advice.

← ea60cf7 Keep released runtime worker locks out of repository snapsho  ·  back to Dw Daily Catchup 20260909  ·  Bound catch-up retries to the 462 verified remaining candida 7ec14eb →