KB-1231

S177 — Architecture Design Document — PATCH2 (2026-05-19)

53 min read Revision 1
s177larkpatch2architecturedesigndraft

S177 — Architecture Design Document: Lark Base Controlled CRUD Gateway — PATCH2

Status: DRAFT v1.2 (PATCH2) — realigns PATCH1 to live source after S177-R0 reconcile. Date: 2026-05-19 Supersedes: s177-architecture-design-2026-05-19-patch1.md (PATCH1, DRAFT v1.1). Pairs with:

  • s177-controlled-crud-gateway-requirements-v2.md (đề bài v2.2 FINAL)
  • s177-r0-code-reconcile-report-2026-05-19.md (R0 — 5 material + 2 partial drifts)
  • s177-oq-decision-record-2026-05-19.md (OQ-2/-3/-4/-5/-7 closed) Author: Claude Code (Opus 4.7) Scope of PATCH2: interface-binding corrections only. No new architectural concept; all SafetyLayer / service / DI / GPG / approval / PII / 2-phase audit structure from PATCH1 is preserved. PATCH2 re-expresses PATCH1 against the actual lark-client source observed in R0.

PATCH2 changelog (11 changes): Q1 CLI = argparse (not Click), nested subparsers + cmd_* dispatch · Q2 exit codes mapped to real enum 0–5 (no enum extension) · Q3 exception hierarchy realigned to LarkClientError / LarkAPIError / KeyError-wrap · Q4 AuditLogger extended with four explicit net-new methods + entry schemas + masking-safety carve-out · Q5 LarkCore.write(...) public guarded entrypoint + client_token semantics + endpoint whitelist additions · Q6 test harness convention added net-new (conftest.py + LARK_TEST_INTEGRATION + Base-đệm hard-assert) · Q7 OQ-7 correction — orphan backups always retained + logged, never auto-deleted inline · Q8 OQ-2/-3/-4/-5 folded as final decisions (no longer open) · Q9 packaging/entrypoint compatibility frozen on cli.lark_tool:main · Q10 allowed_endpoints.yaml write-section format documented as observed; net-new additions enumerated · Q11 existing ungated live-API tests quarantined behind same LARK_TEST_INTEGRATION gate.

Sections changed vs PATCH1: A (risks/sprint), B.3, B.4, C.2 (layer 7 keeps OQ-3 split; layer 8 routes via LarkCore.write), C.3 (no change), C.6 (orphan retention), D.3 (OQ-3 fold), E (OQ-2 fold), F.1/F.3 (OQ-4 fold), G.3 (Click → argparse), G.5 (write enumeration + client_token), H.2/H.3 (test gate net-new + quarantine), I (Sprint 0 closed; Sprint 1 acceptance updated), J (OQ-2/-3/-4/-5/-7 closed; OQ-1 closed via R0; OQ-6 remains environment-only).


A. Executive Summary

Scope (unchanged from PATCH1)

Add controlled write capability (records first, then fields, then table/view schema) to the existing read-only lark-client v1.0.0, behind a mandatory 8-layer SafetyLayer, exposed through two tracks that share one Application Service Layer:

  • Track B (CLI, production-grade, built first): lark-tool records … / lark-tool fields … registered into the existing argparse CLI in cli/lark_tool.py.
  • Track A (MCP, Cowork-interactive): custom adapter over the same service layer; production delete forbidden, Base đệm only.

Architecture (target — unchanged shape, corrected boundaries)

Cowork / MCP (Track A)          Claude Code CLI / Cron (Track B — argparse cmd_records_*/cmd_fields_*)
        │                                  │
        └────────────────┬─────────────────┘
                         ▼
          Application Service Layer  (lark_client/service.py)
          — single write entrypoint, no duplicated write logic —
                         ▼
              SafetyLayer  (lark_client/safety.py)
   dry-run → approval(atomic) → backup(GPG) → audit-pre → lock →
   rate-limit → PII-scan → LarkCore.write(…) → audit-post → release
                         ▼
                   LarkCore  (lark_client/core.py)
        — existing _request kept private; new public LarkCore.write(...) is
          the ONLY external write entrypoint; reuses GSM token + whitelist
          + token-bucket lock + retry; attaches client_token where supported —
                         ▼
            Lark Open API  https://open.larksuite.com

Sprint plan (PATCH2)

Sprint Deliverable Track
0 (S177-R0) CLOSED — code reconcile complete, 5 material + 2 partial drifts captured, PATCH2 issued
1 service.py (records) + safety.py (8 layers, atomic approval, orphan-backup) + LarkCore.write + AuditLogger extension + GPG backup + argparse records subparser + conftest.py + LARK_TEST_INTEGRATION gate + quarantine of existing live tests + unit/mock tests B
2 Custom MCP adapter over service layer + record.get/record.delete MCP A
3 field_manager.py (Text / Number / SingleSelect / Checkbox) + DirectusApprovalProvider swap + provider-swap contract test B
4 Table / base schema ops + view list/create/delete + monitoring (audit volume, audit loss, orphan sweep) + full integration A+B

Risk register (updated)

# Risk Sev Mitigation
R-1 Design not validated vs live source CLOSED (Sprint 0 done) S177-R0 done; PATCH2 issued
R-2 Audit-post fail after destructive API success HIGH AuditLogger.log_write_emergency on independent fd to a separate sink (§C.6, §P2-3 below)
R-3 PII leaking into logs/exports HIGH 2 parallel PII layers; metadata-only audit; non-GPG egress blocks (§D.3, OQ-3)
R-4 GPG private key on VPS HIGH Public-key-only on VPS; private key offline (OQ-2 confirmed)
R-5 Batch partial failure / oversized batch MED config/lark-api-limits.yaml; record_delete_max=100; stop-and-report (OQ-5)
R-6 Plugin write tools bypass SafetyLayer HIGH Plugin keeps read tools only or is fully replaced (OQ-4 confirmed)
R-7 Cowork/MCP hitting production not Base đệm MED Hard app_token allowlist at adapter boundary (§F.3)
R-8 Approval one-time double-spend under concurrency MED Atomic file-locked check-and-consume (§C.3)
R-9 Orphan GPG backup if audit-pre fails after backup LOW Always retain + log to /var/log/lark-ops/orphan-backups.log; sweep is separate, audited, 7-day grace (OQ-7 fold, §C.6)
R-10 (new, R0) Drift between design and live source RESOLVED in PATCH2 All five material drifts mapped to net-new design corrections (§B.3, §G.3, §C.6/§P2-3, §G.5/§P2-4, §H.3/§P2-5)
R-11 (new, R0) Existing live-API tests hit Lark unconditionally MED Quarantined behind LARK_TEST_INTEGRATION=1 + Base-đệm token assert in Sprint 1 prerequisite step (§P2-11)

P2-1. CLI argparse redesign (R0 drift #1)

Source baseline (from R0)

cli/lark_tool.py uses argparse with nested subparsers and per-command cmd_* functions; main(argv) does manual dispatch. Existing top-level subparser groups: registry, schema, audit. Submodules already exist for cli.dump and cli.summarize. No Click anywhere. Entry point in pyproject.toml [project.scripts]: cli.lark_tool:main. PATCH2 must NOT introduce Click, must NOT create a new entrypoint, and must NOT fork the CLI.

Design — register records and fields as new top-level subparser groups

In cli/lark_tool.py, the existing subparsers = parser.add_subparsers(dest="cmd") (or its current name) gets two new groups added by the same pattern already used by registry/schema/audit:

# inside cli/lark_tool.py (additive only)
import cli.records as records_cli   # new module
import cli.fields  as fields_cli    # new module (Sprint 3)

records_cli.register(subparsers)   # registers `records` group
fields_cli.register(subparsers)    # registers `fields` group  (Sprint 3)

cli/records.py (Sprint 1) follows the same cmd_* convention already in cli/lark_tool.py:

# cli/records.py  (Sprint 1 — illustrative shape, NOT code to commit here)
import argparse, json, sys
from cli.lark_tool import (
    EXIT_OK, EXIT_USER_ERROR, EXIT_NETWORK_API,
    EXIT_INTERNAL, EXIT_PERMISSION_CONFIG, EXIT_CRED_LOST,
)
from lark_client.service import LarkWriteService, WriteContext
from lark_client.exceptions import (
    LarkClientError, LarkAPIError, ApprovalError, SafetyViolation,
    PartialFailureError, AuditWriteError, UnknownBaseError,
    CredentialPermissionLost,
)

def register(subparsers: argparse._SubParsersAction) -> None:
    p = subparsers.add_parser("records", help="record write ops (CRUD)")
    sp = p.add_subparsers(dest="subcmd", required=True)

    pc = sp.add_parser("create",  help="create one record")
    pc.add_argument("base_key");  pc.add_argument("table_id")
    pc.add_argument("--data", required=True, help="JSON record body")
    pc.add_argument("--approval", required=True)
    pc.add_argument("--no-dry-run", action="store_true")
    pc.add_argument("--confirm", action="store_true")
    pc.set_defaults(func=cmd_records_create)

    pu = sp.add_parser("update",  help="update one record")
    pu.add_argument("base_key");  pu.add_argument("table_id"); pu.add_argument("record_id")
    pu.add_argument("--data", required=True)
    pu.add_argument("--approval", required=True)
    pu.add_argument("--no-dry-run", action="store_true")
    pu.add_argument("--confirm", action="store_true")
    pu.set_defaults(func=cmd_records_update)

    pd = sp.add_parser("delete",  help="delete one record")
    pd.add_argument("base_key");  pd.add_argument("table_id"); pd.add_argument("record_id")
    pd.add_argument("--approval", required=True)
    pd.add_argument("--no-dry-run", action="store_true")
    pd.add_argument("--confirm", action="store_true")
    pd.set_defaults(func=cmd_records_delete)

    pg = sp.add_parser("get", help="get one record by id (read-only)")
    pg.add_argument("base_key"); pg.add_argument("table_id"); pg.add_argument("record_id")
    pg.set_defaults(func=cmd_records_get)

    # batch_create / batch_update / batch_delete follow the same shape with
    # --input <file.jsonl> in place of --data; same flag set.

def cmd_records_create(args, *, svc: LarkWriteService) -> int:
    try:
        ctx = WriteContext(
            base_key=args.base_key,
            table_id=args.table_id,
            operation="record.create",
            agent=_resolve_agent(args),
            approval_id=args.approval,
            dry_run=not args.no_dry_run,
            confirmed=args.confirm,
        )
        outcome = svc.create_record(ctx, json.loads(args.data))
        json.dump(outcome, sys.stdout); sys.stdout.write("\n")
        return EXIT_OK
    except ApprovalError:        return EXIT_PERMISSION_CONFIG
    except SafetyViolation as e: return _safety_exit(e)
    except UnknownBaseError:     return EXIT_USER_ERROR
    except LarkAPIError:         return EXIT_NETWORK_API
    except CredentialPermissionLost: return EXIT_CRED_LOST
    except PartialFailureError:  return EXIT_INTERNAL   # see §P2-2 routing table
    except AuditWriteError as e: return _audit_exit(e)
    except LarkClientError:      return EXIT_INTERNAL
    except Exception:            return EXIT_INTERNAL

Rules binding all Track-B CLI work:

  1. No new top-level entrypoint. Only cli.lark_tool:main exists. New commands hook into its existing subparsers.
  2. Every new command file (cli/records.py, cli/fields.py) exports a register(subparsers) function and one cmd_* per subcommand. No new dispatch loop; cli.lark_tool.main already does it.
  3. Flags are added with parser.add_argument(...) only. No Click decorators, no @click.command, no @click.option.
  4. --no-dry-run is store_true (default False → dry-run ON). --confirm is store_true. --data and --approval are required strings for the relevant commands.
  5. update, delete, batch-update, batch-delete on a non-buffer base additionally require --confirm (enforced inside SafetyLayer layer 1, not at argparse level, so error contract is consistent for both CLI and MCP).
  6. fields delete requires the literal ack string "tôi hiểu không thể undo" passed via --confirm-ack (separate flag; not a boolean) — checked inside SafetyLayer.
  7. $LARK_AGENT resolution remains §G.3 PATCH1 logic; CLI layer reads os.environ.get("LARK_AGENT") and passes via WriteContext.agent. Real write with missing/empty $LARK_AGENT → SafetyLayer layer-1 raises SafetyViolation. Dry-run-only convenience default (claude-code) is allowed.

P2-2. Exit code / exception contract (R0 drift #2 + partial #6, #7)

Real enum (from source, immutable in Sprint 1)

# in cli/lark_tool.py (already present in source — do not modify)
EXIT_OK                = 0
EXIT_USER_ERROR        = 1
EXIT_NETWORK_API       = 2
EXIT_INTERNAL          = 3
EXIT_PERMISSION_CONFIG = 4
EXIT_CRED_LOST         = 5

Decision — DO NOT extend the enum in Sprint 1

PATCH2 maps every new failure into the existing 05 enum. Rationale:

  • Sprint 1 stays interface-binding to current source; we don't open a separate change to cli/lark_tool.py's exit-code module.
  • 5 is dedicated to credential-lost (CredentialPermissionLost); never reused for any other failure (including partial failure — see below).
  • The WriteOutcome.status field (dry_run | success | partial_failure | failed | aborted) carries the operational distinction that callers need; shell scripts that need finer granularity parse the JSON on stdout, not the exit code.

Future option (NOT Sprint 1): if shell-script needs justify it, a follow-up patch could introduce EXIT_PARTIAL_FAILURE = 6 as an additive enum extension. Out of scope for PATCH2.

Routing table — exception → exit code

Exception (Sprint-1 surface) Parent Bucket meaning Exit code
SafetyViolation (dry-run-not-confirmed, scope mismatch detectable by user, missing --confirm, missing $LARK_AGENT) LarkClientError user error (caller passed wrong flags or context) EXIT_USER_ERROR (1)
UnknownBaseError (wraps Registry.KeyError) LarkClientError user passed unknown base_key EXIT_USER_ERROR (1)
LarkAPIError (status_code, code, msg — existing) LarkClientError Lark Open API returned an error after retries EXIT_NETWORK_API (2)
LarkClientError (generic, unmapped) itself internal lark-client error EXIT_INTERNAL (3)
PartialFailureError (some chunks committed, some not) LarkClientError partial failure — manual rollback expected EXIT_INTERNAL (3) + WriteOutcome.status="partial_failure"
AuditWriteError raised pre-API (audit-pre failed) LarkClientError system invariant violated; no mutation occurred EXIT_INTERNAL (3)
AuditWriteError raised post-API (audit-post degraded; API already succeeded) LarkClientError success-with-warning; emergency fallback sink written EXIT_OK (0) + WriteOutcome.status="success" + error="audit_post_degraded"
ApprovalError (missing / expired / scope-mismatch / already-consumed / approval-lock contention) LarkClientError approval registry says no — this is a permission/config issue EXIT_PERMISSION_CONFIG (4)
EndpointNotAllowed (existing in exceptions.py) LarkClientError write endpoint not on whitelist EXIT_PERMISSION_CONFIG (4)
RateLimitExceeded (existing — internal token-bucket wait surfaced) LarkClientError local rate limiter exhausted (rare; usually retried inside core) EXIT_INTERNAL (3)
TokenRefreshError (existing) LarkClientError OAuth refresh failed without credential loss EXIT_INTERNAL (3)
CredentialPermissionLost (existing) LarkClientError credential revoked / GSM access lost EXIT_CRED_LOST (5)

Rules:

  1. Every CLI cmd_* handler wraps its body in a single try/except chain over the above exceptions in the order shown (most specific first). Unknown exceptions → EXIT_INTERNAL.
  2. EXIT_CRED_LOST (5) is reserved for CredentialPermissionLost only. Partial failure is never code 5.
  3. EXIT_PERMISSION_CONFIG (4) covers both approval mis-config and endpoint-whitelist refusal; these are operationally the same bucket (operator must touch a YAML).
  4. audit_post_degraded (post-API audit failure where the API already mutated) returns EXIT_OK with a non-empty WriteOutcome.error. This is mandated by req §9 ("the write succeeded — never re-trigger it") and is what the emergency fallback sink exists to record. Caller scripts must check WriteOutcome.error, not just the exit code, to detect degraded audits.
  5. The CLI prints WriteOutcome (or read response) as JSON to stdout on every code path that returns 0. Errors print a short error JSON to stderr.

P2-3. AuditLogger extension spec (R0 drift #5)

Source baseline (from R0)

AuditLogger (lark_client/audit.py) is append-only JSONL to /var/log/lark-ops/YYYYMMDD.jsonl; the only public method is log_call(agent, command, endpoint, method, status, duration_ms, api_calls, error, request_id); _write(entry) masks credential-like strings ≥30 chars and skips the keys ts, agent, cmd. There is no log_planned, no emergency sink, no orphan log, no phase field, no idempotency_key field in the existing schema.

Net-new methods (Sprint 1)

Four methods are added to AuditLogger. They share the existing _write plumbing where safe, but each writes a distinct entry shape and (for emergency / orphan) a distinct sink.

class AuditLogger:
    # existing
    def log_call(self, *, agent, command, endpoint, method, status,
                 duration_ms, api_calls, error, request_id) -> None: ...
    def log_cli_invocation(self, *, agent, argv, exit_code) -> None: ...
    def _write(self, entry) -> None: ...   # private — append+fsync to today's JSONL

    # NEW — Sprint 1
    def log_write_planned(self, ctx, *, backup_ref, audit_pre_id) -> str:
        """Phase 1. Append+fsync planned entry to PRIMARY sink. Raises AuditWriteError
        on any failure; caller (SafetyLayer layer 4) MUST abort BEFORE calling the
        Lark API. Returns the audit_pre_id used (UUID v4)."""

    def log_write_result(self, ctx, *, audit_pre_id, outcome) -> None:
        """Phase 3. Append+fsync result entry to PRIMARY sink, correlated to
        audit_pre_id. If this raises AuditWriteError AFTER the API has already
        succeeded, SafetyLayer must call log_write_emergency."""

    def log_write_emergency(self, ctx, *, audit_pre_id, outcome, error) -> str:
        """Independent emergency sink. Open a NEW file descriptor under
        /var/log/lark-ops/EMERGENCY/<YYYYMMDD>/<ts>-<idempotency_key>.json.
        MUST NOT reuse the same fd / same sink as the primary _write path,
        because that is the path that just failed. fsync the new file before
        returning. If even the emergency sink fails: write a single line to
        stderr 'LARK-AUDIT-LOST id=<idempotency_key> reason=<...>' and raise
        AuditWriteError — never silently swallow. Returns the emergency file path."""

    def log_orphan_backup(self, ctx, *, backup_path, key_fingerprint, reason) -> None:
        """Metadata-only line appended+fsynced to /var/log/lark-ops/orphan-backups.log
        Never contains raw PII (only path + fingerprint + idempotency_key + reason + ts).
        Used by SafetyLayer when a GPG backup at layer 3 has become orphaned because
        audit-pre at layer 4 failed (OQ-7: NEVER auto-delete inline)."""

Entry JSON schemas

log_write_planned (PRIMARY sink, today's JSONL):

{
  "ts": "2026-05-20T03:14:15.123Z",
  "phase": "planned",
  "audit_pre_id": "<uuid-v4>",
  "operation_id": "<uuid-v4 ≡ idempotency_key>",
  "idempotency_key": "<uuid-v4>",
  "agent": "claude-code",
  "cmd": "records.update",
  "op": "record.update",
  "base_key": "88-phai-cu-base-dem",
  "table_id": "tblPQ6N79EeOmnTm",
  "targets": ["rec…"],
  "target_count": 1,
  "approval_id": "APR-001",
  "backup_ref": "/var/log/lark-ops/writes/20260520/88-…__tbl…__rec…__<id>__pre.json.gpg",
  "dry_run": false,
  "confirmed": true,
  "is_buffer_base": true
}

log_write_result (PRIMARY sink):

{
  "ts": "2026-05-20T03:14:16.045Z",
  "phase": "success",
  "audit_pre_id": "<uuid-v4>",
  "operation_id": "<uuid-v4>",
  "idempotency_key": "<uuid-v4>",
  "agent": "claude-code",
  "cmd": "records.update",
  "op": "record.update",
  "base_key": "88-phai-cu-base-dem",
  "table_id": "tblPQ6N79EeOmnTm",
  "request_id": "<lark X-Tt-Logid or returned id>",
  "lark_response_meta": {"code": 0, "msg": "ok", "http": 200},
  "duration_ms": 312,
  "pii": {"pii_redacted": true, "redaction_types": ["national_id_cccd"], "redacted_fields_count": 1, "detector": ["registry","pattern"]},
  "outcome_status": "success",
  "error": null
}

phase{"success", "failed", "partial_failure"} for the result entry.

log_write_emergency (EMERGENCY sink, separate file per call):

{
  "ts": "...",
  "phase": "emergency_post_audit",
  "audit_pre_id": "<uuid-v4>",
  "operation_id": "<uuid-v4>",
  "idempotency_key": "<uuid-v4>",
  "agent": "claude-code",
  "cmd": "records.update",
  "lark_response_meta": {"...": "as it was at moment of API success"},
  "primary_audit_error": "<exception message — masked>",
  "outcome_status": "success",
  "error": "audit_post_degraded"
}

log_orphan_backup (/var/log/lark-ops/orphan-backups.log, append-only):

{
  "ts": "...",
  "idempotency_key": "<uuid-v4>",
  "operation_id": "<uuid-v4>",
  "backup_path": "/var/log/lark-ops/writes/20260520/...__pre.json.gpg",
  "key_fingerprint": "ABCD1234EF…",
  "reason": "audit_pre_failed",
  "agent": "claude-code",
  "op": "record.update",
  "base_key": "88-phai-cu-base-dem",
  "table_id": "tblPQ6N79EeOmnTm"
}

Ordering invariants (binding for SafetyLayer implementation)

  1. log_write_planned MUST append + fsync BEFORE the API closure runs. If log_write_planned raises AuditWriteError, the API closure MUST NOT be called.
  2. If log_write_planned succeeded but log_write_result fails, AND the API call did succeed, log_write_emergency MUST be invoked. The emergency sink is a separate file (different inode, different directory) opened with a separate file descriptor — the failure mode that motivates this is "primary sink unwritable" (disk-full, EIO, permissions), so we must not retry on the same path.
  3. log_orphan_backup is invoked only from SafetyLayer when layer 3 produced a backup but layer 4 (audit-pre) failed. It MUST run before the abort propagates, because once we abort we lose the context needed to identify the orphan.
  4. log_write_emergency and log_orphan_backup themselves invoke a _write_to(path, entry) helper variant that opens its own file, fsyncs, closes — they MUST NOT share the cached today's JSONL handle that may have just failed.

Masking-safety carve-out (mandatory)

_write currently masks credential-like strings ≥30 chars, skipping only ts, agent, cmd. The fields PATCH2 introduces are strings ≥30 chars but are not credentials:

Field Length Why it must not be masked
idempotency_key 36 (UUID v4) Used for correlation across phases, orphan log, emergency log; rollback command refers to it
operation_id 36 Synonym/alias of idempotency_key; carried for forward compatibility
audit_pre_id 36 Joins phase-1 ↔ phase-3 entries
backup_ref path (often ≥30 chars) Operator must be able to find the encrypted backup file from the audit log
request_id varies Joins our trace to Lark's logid for support
key_fingerprint 40 (SHA-1) or 64 (SHA-256) Identifies which GPG public key encrypted a given backup
approval_id e.g. APR-001 (usually <30) Already short — likely passes today, but explicitly whitelisted to be safe
base_key, table_id variable Operationally meaningful, never a credential
backup_path path same as backup_ref

Change to _write's skip-list: extend from {ts, agent, cmd} to:

_MASK_SKIP_KEYS = {
    "ts", "agent", "cmd",
    "audit_pre_id", "operation_id", "idempotency_key",
    "backup_ref", "backup_path", "key_fingerprint",
    "request_id", "approval_id",
    "base_key", "table_id", "phase", "op", "outcome_status",
    "target_count", "duration_ms", "dry_run", "confirmed",
    "is_buffer_base",
}

PII metadata fields (pii_redacted, redaction_types, redacted_fields_count, detector) are short and not credential-shaped — they will not trip the ≥30-char heuristic, but they are explicitly listed as PII-safe metadata (no raw values).

Raw-PII rule (mandatory)

Audit entries MUST NOT contain raw PII (req §6, §13, OQ-3). Specifically:

  • targets MAY include record_id (Lark identifier) but MUST NOT include record body fields.
  • lark_response_meta is a small object of code/msg/http; if Lark returns the created record body, that body MUST NOT be inlined into the audit entry — only request_id / code / msg / http.
  • pii is metadata only — redaction_types[], redacted_fields_count, detector[]. Never redacted_values.

P2-4. LarkCore public guarded-write method (R0 drift #4)

Source baseline (from R0)

LarkCore.core.py has:

  • private _request(method, endpoint, *, json_data, params, timeout, _audit_cmd) — the only HTTP write path,
  • private paginate(...),
  • endpoint whitelist loaded from config/allowed_endpoints.yaml covering both read: and write:, with write: currently empty,
  • token-bucket rate limiter (fcntl.flock on /var/lock/lark-api.lock + /var/lock/lark-api.state, _MAX_RPS=10),
  • retry on {429, 503, ConnectionError, Timeout} ×3 backoff [1, 2, 4],
  • requests imported inside core only.

The PATCH1 assumption that LarkCore exposes a public write method, or accepts an idempotency_key/client_token parameter, is wrong. PATCH2 introduces both as net-new.

Design — LarkCore.write(...)

class LarkCore:
    # existing private _request stays unchanged in shape; PATCH2 only adds the
    # public wrapper below.

    def write(
        self,
        method: str,                   # "POST" | "PUT" | "DELETE" | "PATCH"
        endpoint: str,                 # must appear in allowed_endpoints.yaml :: write:
        *,
        json_data: dict | None = None,
        params: dict | None = None,
        timeout: float | None = None,
        idempotency_key: str | None = None,   # UUID v4; required for record write
        client_token_supported: bool = False, # endpoint-level flag (see whitelist format below)
        _audit_cmd: str | None = None,
    ) -> dict:
        """Public guarded write entrypoint.
        - Verifies endpoint is in the loaded whitelist `write:` section.
          Endpoint missing → raise EndpointNotAllowed (existing exception).
        - If `client_token_supported` is True, attaches `client_token` to
          json_data automatically: json_data = {**(json_data or {}), 'client_token': idempotency_key}
          IFF idempotency_key is provided. (Some Lark write endpoints accept
          client_token as a JSON body field; some as a query param; the
          per-endpoint whitelist row carries that detail — see §P2-10.)
        - If `client_token_supported` is False, the idempotency_key is NOT sent
          to Lark; it is the caller's job (SafetyLayer / AuditLogger) to record
          it locally so retries can be deduplicated by the operator/audit.
        - Reuses the existing token cache, retry policy, rate-limit lock,
          and endpoint-whitelist check.
        - Internally calls self._request(method, endpoint, json_data=..., params=...,
          timeout=..., _audit_cmd=_audit_cmd) — no business logic duplicated.
        - Returns the parsed JSON response on 2xx; raises LarkAPIError on 4xx/5xx
          after retries; raises CredentialPermissionLost on credential failures
          (existing behaviour of _request)."""

Boundary rules (binding)

  1. _request stays private. No caller outside lark_client/core.py may call _request directly. SafetyLayer calls LarkCore.write(...). LarkReader already uses paginate(...) (read-only) which internally uses _request — that is fine.
  2. SafetyLayer.guard(...) is the only external caller of LarkCore.write. Service / writer / MCP adapter / CLI all funnel through SafetyLayer.guard(ctx, payload, api_call) where api_call is a closure that does exactly one core.write(...).
  3. No import requests outside core.py. (Confirmed match in R0; PATCH2 keeps this rule.)
  4. No bypassing the whitelist. LarkCore.write raises EndpointNotAllowed if the endpoint is not in the loaded write: section.
  5. Lint test (CI): a unit test greps the repo (excluding core.py and tests/) for \b_request\( and import\s+requests\b and fails if either is found in non-core code. This is the existing "no direct requests outside core" rule extended.

client_token semantics

  • Endpoints that accept client_token (per Lark Open API docs for bitable record create/update/batch_create/batch_update at time of writing): client_token deduplicates retries server-side. PATCH2 passes idempotency_key (UUID v4) as client_token for those endpoints automatically.
  • Endpoints that do NOT accept client_token (e.g. DELETE /…/records/:id and some batch_delete variants): idempotency_key is still generated and propagated through SafetyLayer + audit + backup + rollback command. The operator can use the idempotency_key to dedupe retries from the audit log. The CLI command line MUST be replayable with the same idempotency_key for a clean retry.
  • PATCH2 forbids assuming all endpoints accept client_token. Per-endpoint support is declared in the whitelist (§P2-10).

allowed_endpoints.yaml :: write: — Sprint 1 additions

Net-new entries (record-class only — fields/tables/views come Sprint 3/4). Final keys and HTTP methods are as documented in Lark Open API for open.larksuite.com:

Sprint Endpoint (path template) HTTP method client_token?
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records POST YES
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_create POST YES
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id PUT NO*
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_update POST YES
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id DELETE NO
1 /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_delete POST NO**

* PATCH2 assumes record update does not accept client_token; verify in Sprint 0.5 doc-citation step. ** PATCH2 assumes batch_delete does not accept client_token; verify in Sprint 0.5 doc-citation step.

Each row above translates to a whitelist entry in allowed_endpoints.yaml :: write:. Format details in §P2-10.


P2-5. Test harness convention (R0 drift #9)

Source baseline (from R0)

tests/test_core.py is pytest-based. It mixes live-API integration (e.g. test_token_obtainable hits real Lark with no mock, ungated) with offline whitelist tests. There is no tests/conftest.py. There is no LARK_TEST_INTEGRATION env-gate convention. The PATCH1 "mirror existing 19/19+8/8 mocked style" was wrong — current tests are NOT a clean mocked baseline.

Design — net-new tests/conftest.py + env gate

Create tests/conftest.py with:

# tests/conftest.py  (Sprint 1 — illustrative shape, NOT code to commit here)
import os, pytest

BASE_BUFFER_KEY   = "88-phai-cu-base-dem"
BASE_BUFFER_TOKEN = "Nf2bb1ExXaYnlksgoyQl72GNgAc"

def _integration_enabled() -> bool:
    return os.environ.get("LARK_TEST_INTEGRATION") == "1"

integration_only = pytest.mark.skipif(
    not _integration_enabled(),
    reason="live Lark integration test — set LARK_TEST_INTEGRATION=1 to enable",
)

def assert_buffer_base_token(token: str) -> None:
    """Hard fail if an integration test is about to touch any base other than Base đệm."""
    if token != BASE_BUFFER_TOKEN:
        raise AssertionError(
            f"integration test refused: token {token!r} is not Base đệm "
            f"({BASE_BUFFER_TOKEN!r}); production targets are forbidden."
        )

def pytest_collection_modifyitems(config, items):
    """If LARK_TEST_INTEGRATION is not '1', skip every test marked 'integration'."""
    if _integration_enabled():
        return
    skip = pytest.mark.skip(reason="LARK_TEST_INTEGRATION!=1")
    for item in items:
        if "integration" in item.keywords:
            item.add_marker(skip)

pytest.ini (or pyproject.toml [tool.pytest.ini_options]) declares the integration marker:

[pytest]
markers =
    integration: live Lark API tests; require LARK_TEST_INTEGRATION=1 + Base đệm token

Rules binding all S177 test code

  1. Default pytest run (no env vars) runs unit/mock tests only. Every test that calls Lark is annotated @pytest.mark.integration. The conftest skips them when the env gate is off.
  2. Integration tests MUST hard-assert Base đệm token via assert_buffer_base_token(token) before any write/delete/schema call. Production token (YSIkb8PxOaNaozs2vwalOOcagkf) is forbidden in tests; assertion error is the fail mode.
  3. No requests in tests. Tests mock LarkCore.write for unit tests; integration tests construct a real LarkCore instance but only against Base đệm.
  4. Schema / delete integration tests are Base-đệm-only, never production (req §13.4).
  5. Production integration tests are categorically forbidden. Even with the env gate on, the token assertion blocks production tokens. A future "production smoke" need (none currently) would require a separate sanctioned flag and a separate review.
  6. app_token literals are allowed only in tests/ and in config/bases.yaml (existing rule; PATCH2 reaffirms).
  7. The unit/mock subset is the Sprint 1 commit gate. Integration green is required before Sprint 1 sign-off but not at every commit.

See §P2-11 for the related quarantine of the EXISTING ungated live test.


P2-6. OQ-7 orphan backup correction

OQ-7 closes as follows; PATCH2 supersedes PATCH1 §C.6 step 1 and PATCH1 §E.5:

  1. NO inline "delete-if-safe" path. Even when the backup is provably from this attempt and no concurrent reader is touching it, do not delete inline. The mutation never happened, but the operator may want the backup for forensic reasons.
  2. Always retain the encrypted orphan blob in its original location under /var/log/lark-ops/writes/<YYYYMMDD>/….
  3. Always record a metadata-only line via AuditLogger.log_orphan_backup(...) to /var/log/lark-ops/orphan-backups.log. The line is the one in §P2-3 above (no raw PII, only path + fingerprint + idempotency_key + reason + ts).
  4. Default grace window: 7 days before any sweep is even eligible to consider a blob.
  5. Sweep is an explicit, audited operator action — not a cron job, not an "if safe" branch in SafetyLayer. The runbook (Sprint 4) defines:
    • lark-tool audit orphan-backups list (read-only — to be added in Sprint 4 as a new subcommand under the existing audit group)
    • lark-tool audit orphan-backups sweep --older-than 7d --confirm-ack "..." --approval APR-… (the sweep itself goes through SafetyLayer; deletes are audited; never bulk-no-approval).
  6. SafetyLayer.guard(...) and AuditLogger ship in Sprint 1 with retention-only logic. The sweep subcommand is a Sprint-4 deliverable (matches §I monitoring sprint). Sprint 1 must not contain any os.remove(backup_path) or unlink call against writes/ blobs.

PATCH1 wording to remove in PATCH2-applied design: "If the backup file can be removed safely … → delete it" — gone. Only step 2 of PATCH1 §C.6 ("leave it and append … orphan-backups.log") survives, and it becomes the only path.


P2-7. Fold OQ decisions into final design

These are no longer open questions. PATCH2 folds them into the design as binding decisions; the OQ register in §J is updated accordingly.

OQ-2 — GPG private key (folded)

  • VPS holds the public key only, fetched from GSM as LARK_BACKUP_GPG_PUBKEY (ASCII-armored).
  • Private key remains offline with Huyên; never on the VPS.
  • No on-VPS decrypt capability in Sprint 1 (or any sprint until a separate ratified decision changes this).
  • §E in PATCH1 stands. No on-VPS gpg --decrypt ever. Recovery procedure is offline.

OQ-3 — PII policy (folded)

  • Guarded record writes: PII detection → log metadata only, proceed. The write itself is not blocked by PII detection.
  • Plaintext / --export / stdout / non-GPG paths: PII detection blocks with SafetyViolation.
  • Audit, stdout, and the auto-generated rollback command MUST NEVER include raw PII. Audit holds metadata only (§D.4 / §P2-3); the rollback command references the encrypted backup file path.
  • --pii-strict (escalate guarded writes to also abort on PII detection) is deferred — not a Sprint 1 deliverable. It is a future toggle, not in §H.2 acceptance criteria.

OQ-4 — MCP topology (folded)

  • Existing @larksuiteoapi/lark-mcp plugin is acceptable only if the MCP host can hide/disable its write tools.
  • Allowed plugin tools (read-class only): appTable_list, appTableField_list, appTableRecord_search, plus any read-only app/table listing the plugin offers.
  • If the host cannot hide appTableRecord_create / _batchCreate / _update / _batchUpdate, appTable_create, app_create → the plugin is fully replaced with the custom adapter.
  • Every write — regardless of topology — goes through the Application Service Layer + SafetyLayer. No unguarded write path is acceptable.
  • The host's hiding capability is verified concretely in Sprint 2 (not assumed); §F.3 acceptance includes the answer.

OQ-5 — record.batch_delete limit (folded)

  • The actual Lark cap for batch_delete is unknown until verified.
  • config/lark-api-limits.yaml :: batch.record_delete_max = 100 is the conservative default.
  • The ceiling is raised only after (a) an official Lark Open API doc citation, or (b) a recorded Base đệm probe (no production probe). The citation/probe lands in knowledge/dev/lark/ and the YAML is updated through a normal review change.
  • Service-side: explicit request > ceiling is rejected (no silent truncation). Auto-chunking only applies when caller did not specify an explicit batch size, and only up to the ceiling.

OQ-7 — Orphan backup (folded — see §P2-6 above for full design)

  • Always retain + log via AuditLogger.log_orphan_backup(...).
  • Default grace 7 days.
  • Sweep is a Sprint 4 deliverable, explicit + audited, never inline + never automatic.

P2-8. Exception alignment (R0 drifts #3 + partial #7)

Live exception hierarchy (from R0)

lark_client.exceptions:

LarkClientError (base)
├── EndpointNotAllowed
├── CredentialPermissionLost
├── TokenRefreshError
├── LarkAPIError            # carries (status_code, code, msg)
└── RateLimitExceeded

Registry.get_by_key / Registry.get_by_app_token raise built-in KeyError, not a domain exception.

Net-new exceptions in Sprint 1 (all subclass LarkClientError)

# lark_client/exceptions.py — additive
class ApprovalError(LarkClientError):
    """Approval registry refused: missing / expired / scope-mismatch /
    wildcard-policy-violated / one-time-already-consumed / lock-contention."""
    def __init__(self, code: str, msg: str = ""):
        self.code = code  # 'missing','expired','scope_mismatch','wildcard_forbidden',
                           # 'already_consumed','approval_locked'
        super().__init__(f"{code}: {msg}" if msg else code)

class SafetyViolation(LarkClientError):
    """SafetyLayer policy violation pre-API. Subtypes carried via .reason
    string: 'dry_run_required','confirm_required','agent_required',
    'audit_pre_failed','lock_held','pii_egress_blocked','pii_scanner_error'."""
    def __init__(self, reason: str, msg: str = ""):
        self.reason = reason
        super().__init__(f"{reason}: {msg}" if msg else reason)

class PartialFailureError(LarkClientError):
    """Batch partially committed. .committed and .failed are lists of
    record-level outcomes; rollback is manual (req §12.10)."""
    def __init__(self, *, committed: list, failed: list, rollback_command: str):
        self.committed = committed
        self.failed = failed
        self.rollback_command = rollback_command
        super().__init__(f"partial_failure: ok={len(committed)} fail={len(failed)}")

class AuditWriteError(LarkClientError):
    """Audit sink write failed. .phase ∈ {'pre','post','emergency','orphan'}."""
    def __init__(self, phase: str, msg: str = ""):
        self.phase = phase
        super().__init__(f"audit_{phase}: {msg}" if msg else f"audit_{phase}")

class UnknownBaseError(LarkClientError):
    """Service-layer wrapper around Registry KeyError when caller passed an
    unknown base_key. Raised by service.py / writer.py only — NEVER by
    Registry itself; Registry continues to raise KeyError (matches source)."""
    def __init__(self, base_key: str):
        self.base_key = base_key
        super().__init__(f"unknown_base_key: {base_key!r}")

Rules

  1. All net-new exceptions subclass LarkClientError. No parallel hierarchy.
  2. LarkAPIError is referenced with the correct caps (capital A, capital P, capital I). PATCH1's "LarkApiError" was a typo and is removed.
  3. Registry.KeyError is wrapped at the service-layer boundary. Inside lark_client/service.py, registry.get_by_key(base_key) is wrapped:
    try:    app_token = self._registry.get_by_key(base_key).app_tokenexcept KeyError as e:    raise UnknownBaseError(base_key) from e
    
    This keeps Registry itself untouched and matches source behaviour; only the service layer translates to a domain exception.
  4. Do not invent exceptions not declared above. LarkClientError subclasses listed here are the complete Sprint-1 net-new set.

P2-9. Packaging / entrypoint compatibility

  • pyproject.toml [project.scripts] entry stays exactly cli.lark_tool:main. PATCH2 does not change packaging metadata, does not add a new console_scripts entry, does not rename the entry, does not introduce a separate lark-write or larksafe binary.
  • No CLI fork. lark-tool remains the single executable. New commands (records, fields) hook into cli.lark_tool.main's existing argparse subparsers (§P2-1).
  • No version bump required for PATCH2 (PATCH2 is design only). Sprint 1 code may bump version.txt to 1.1.0 or similar at commit time; that is a Sprint 1 implementation choice, not a design requirement.
  • No new dependencies are introduced by PATCH2's design. Sprint 1 implementation may add python-gnupg (or shell out to gpg) for backup encryption — that decision lands in the Sprint 1 command package, not here.

P2-10. allowed_endpoints.yaml write: format

Format observed in source (R0)

R0 confirmed: config/allowed_endpoints.yaml has both a read: section (populated — tables/fields/views/records/search/workflows) and a write: section (currently []). LarkCore loads both and uses them in the whitelist check inside _request. R0 did not disclose whether each list entry is a bare string (an endpoint path / regex / template) or a structured object — PATCH2 does not invent a new schema and treats the observed shape as authoritative.

PATCH2 rule

  1. Do not invent a new YAML schema for write:. Net-new write endpoints are added in the same shape the existing read: entries use.

  2. If the existing shape is bare strings (path templates): Sprint 1 adds the six record-class endpoints (§P2-4 table) as bare string entries under write:. The client_token support flag is then declared in a sibling file config/lark-api-limits.yaml :: write_endpoint_options: (additive, does not modify existing schema):

    # config/lark-api-limits.yaml  (Sprint 1)
    rate:
      requests_per_sec: 10
    batch:
      record_create_max: 500
      record_update_max: 500
      record_delete_max: 100
    write_endpoint_options:
      # endpoint_path_template -> per-endpoint metadata
      "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records":
        methods: ["POST"]
        client_token: true
      "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_create":
        methods: ["POST"]
        client_token: true
      "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/:record_id":
        methods: ["PUT","DELETE"]
        client_token: false
      "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_update":
        methods: ["POST"]
        client_token: true
      "/open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/batch_delete":
        methods: ["POST"]
        client_token: false
    
  3. If the existing shape is structured objects (dicts with method + client_token fields): the write_endpoint_options: block above collapses into per-entry fields under write:, and lark-api-limits.yaml only carries rate/batch. Sprint 1 picks the layout that matches what allowed_endpoints.yaml already does.

  4. Decision deferred to Sprint 0.5 doc-citation step (a 30-minute read of the existing file). PATCH2 captures both shapes and is unambiguous about which fields each carries; Sprint 1 implementation picks the shape that already lives in the file. Either way, no new schema is invented; the format is observed and matched.

  5. Backward compatibility: if write_endpoint_options: is added to lark-api-limits.yaml, LarkCore reads it optionally — absence means "all whitelisted endpoints treated as client_token=False" so existing read-only deployments are unaffected.


P2-11. Existing live-test quarantine (R0 drift #9 carry-forward)

Risk (from R0)

tests/test_core.py already contains live-API tests that run with no env gate (test_token_obtainable hits real Lark). A new developer running pytest cold today already calls Lark and burns credentials/quotas without consent.

Decision — Option A (preferred): quarantine in Sprint 1 prerequisite step

Sprint 1 includes a prerequisite micro-step (before any new write code touches service.py / safety.py) that:

  1. Adds tests/conftest.py per §P2-5.
  2. Adds pytest.ini / [tool.pytest.ini_options] markers per §P2-5.
  3. Walks tests/test_core.py and identifies every test that calls real Lark — currently test_token_obtainable plus any others discovered in the same file. Each such test gets:
    @pytest.mark.integrationdef test_token_obtainable(...):    ...
    
    This change is minimal — no test body is modified; only a decorator is added.
  4. Re-runs pytest cold (no env vars) and confirms previously-live tests are now skipped, and every remaining test still passes.
  5. Re-runs pytest with LARK_TEST_INTEGRATION=1 and confirms the live tests run and pass against Base đệm only.

This is a non-blocking edit relative to the rest of Sprint 1: the quarantine ships in the same commit as conftest.py, before service.py / safety.py / core.write. If for any reason the quarantine cannot be done in the same Sprint 1 commit, Option B applies:

Option B (fallback)

Treat the existing ungated live tests as a baseline remediation item tracked separately, not bundled with the unit-gate. New write/integration tests in Sprint 1 still MUST be gated (they are net-new and never had a non-gated history). The existing ungated tests remain a known risk in the risk register until a follow-up patch closes them. Recommend Option A.

Hard rule (regardless of A or B)

New write/integration tests in Sprint 1 are gated by LARK_TEST_INTEGRATION=1 from day one. They never run by default. They always hard-assert Base đệm token before any write. This is non-negotiable.


Sections from PATCH1 carried forward without change

The following PATCH1 sections are still valid as-is (no R0 drift; no OQ override):

  • §B.1 (WriteOutcome, LarkWriteServiceABC, WriteContext) — interface unchanged.
  • §B.2 (LarkWriteService concrete) — DI + Registry resolution.
  • §B.4 (rate-limit & batch sizing via lark-api-limits.yaml) — unchanged (now augmented by §P2-10 for write_endpoint_options:).
  • §C.1 (execution order of 10 layers) — unchanged.
  • §C.3 (atomic approval check-and-consume + DI) — unchanged.
  • §C.4 (wildcard / first-write policy) — unchanged.
  • §C.5 (approval defaults per operation) — unchanged.
  • §D.1 (FieldPIIRegistry) and §D.2 (PatternPIIDetector regex set) — unchanged.
  • §D.3 (PII pipeline policy) — confirmed by OQ-3; --pii-strict deferred (§P2-7).
  • §D.4 (audit redaction format) — unchanged.
  • §E.1–E.4 (GPG key source, rotation, naming, recovery) — confirmed by OQ-2.
  • §F.1–F.3 (MCP topology) — confirmed by OQ-4; OQ-4 reduces to a feasibility check answered in Sprint 2.
  • §G.1 (writer.py typed façade) — unchanged.
  • §G.2 (field_manager.py Sprint 3 scope) — unchanged.
  • §G.4 (write-approvals.yaml schema) — unchanged.

J. Open Questions (updated)

OQ Status Resolution
OQ-1 (code reconcile) CLOSED S177-R0 complete; this PATCH2 is the design realignment
OQ-2 (GPG key) CLOSED — folded §P2-7: VPS public-key only; private key offline; no on-VPS decrypt in Sprint 1
OQ-3 (PII policy) CLOSED — folded §P2-7: two-rule split; --pii-strict deferred (not Sprint 1)
OQ-4 (MCP topology) CLOSED — folded (concrete feasibility check remains for Sprint 2) §P2-7: plugin read-only-if-hideable else full replace; all writes via service + SafetyLayer
OQ-5 (batch_delete limit) CLOSED — folded §P2-7: default 100 in lark-api-limits.yaml; raise only after doc-citation or Base-đệm probe
OQ-6 (commit / path mechanics) OPEN — environment-only This patch lives in KB only. Final repo path /opt/incomex/lark-client/ not populated. Commit requires repo+shell-capable actor; outside the scope of any agent-side patch
OQ-7 (orphan backup) CLOSED — folded §P2-6: always retain + log; 7-day grace; sweep is explicit, audited, Sprint 4
OQ-8 (NEW — Lark client_token per-endpoint support) OPEN — Sprint 0.5 micro-task §P2-4 / §P2-10 — verify per-endpoint via official Lark docs; record citation in KB; update write_endpoint_options: accordingly
OQ-9 (NEW — exact shape of existing write: entries in allowed_endpoints.yaml) OPEN — Sprint 0.5 micro-task §P2-10 — 30-min read of the existing file; pick bare-string vs structured-object shape; do not invent new schema

OQ-6, OQ-8, OQ-9 are not blockers for issuing PATCH2 as a design. OQ-8 and OQ-9 are short read-only verifications that land in the Sprint 1 micro-prereq step alongside the conftest quarantine (§P2-11). OQ-6 is an environment limitation (no repo+shell access here) and is handled by whichever actor commits the design.


Final design status (binding statement)

  • Design commit: RECOMMENDED. PATCH2 closes every material drift in R0 and folds every OQ decision. The architecture (SafetyLayer, service DI, GPG backup, atomic approval, PII layers, 2-phase audit) is unchanged from PATCH1; only the interface bindings have been corrected. Commit gate is OQ-6 (environment-only).
  • Sprint 1 implementation package: CAN BE PREPARED NEXT. PATCH2 is sufficient to author the Sprint 1 command-review document (file list, exact API surfaces, conftest text, exception subclasses, exit-code routing table, endpoint whitelist additions, lark-api-limits.yaml content, test inventory T1–T12 + b-variants). Preparation of that package is itself the next sanctioned step; it requires its own GPT/User authorization and does not start here.
  • Sprint 1 code: NOT AUTHORIZED. Sprint 1 code authoring requires (a) PATCH2 acceptance by GPT/User, (b) a Sprint 1 command-review package approved separately, (c) OQ-6 commit mechanics resolved by a repo+shell actor.
  • No code, no source mutation, no commit, no Lark write, no deploy, no self-advance were performed in PATCH2.

End of S177 Architecture Design — PATCH2. DRAFT v1.2, KB-only, awaiting GPT/User acceptance. Not commit-ready until OQ-6 environment access is available. Sprint 1 code remains gated.

Back to Knowledge Hub knowledge/dev/lark/s177-controlled-crud-gateway/s177-architecture-design-2026-05-19-patch2.md